<?xml version="1.0" encoding="UTF-8"?>
<rss version="2.0" xmlns:atom="http://www.w3.org/2005/Atom" xmlns:dc="http://purl.org/dc/elements/1.1/">
  <channel>
    <title>DEV Community: Jules Robineau</title>
    <description>The latest articles on DEV Community by Jules Robineau (@julesrobineau).</description>
    <link>https://dev.to/julesrobineau</link>
    <image>
      <url>https://media2.dev.to/dynamic/image/width=90,height=90,fit=cover,gravity=auto,format=auto/https:%2F%2Fdev-to-uploads.s3.us-east-2.amazonaws.com%2Fuploads%2Fuser%2Fprofile_image%2F4033931%2F6c7e37e2-5d4d-486b-81c8-591c267775cd.jpg</url>
      <title>DEV Community: Jules Robineau</title>
      <link>https://dev.to/julesrobineau</link>
    </image>
    <atom:link rel="self" type="application/rss+xml" href="https://dev.to/feed/julesrobineau"/>
    <language>en</language>
    <item>
      <title>Give Your AI Agent an Identity, Not Your API Key</title>
      <dc:creator>Jules Robineau</dc:creator>
      <pubDate>Fri, 31 Jul 2026 07:00:00 +0000</pubDate>
      <link>https://dev.to/julesrobineau/give-your-ai-agent-an-identity-not-your-api-key-34fh</link>
      <guid>https://dev.to/julesrobineau/give-your-ai-agent-an-identity-not-your-api-key-34fh</guid>
      <description>&lt;p&gt;You paste an API key into your agent. One line in a &lt;code&gt;.env&lt;/code&gt; file. It works right away.&lt;/p&gt;

&lt;p&gt;But that key is yours. Your access, your reach, your name on every call.&lt;/p&gt;

&lt;p&gt;You just handed your identity to a piece of software that writes its own commands.&lt;/p&gt;

&lt;p&gt;An agent is not you. It is a new actor on your system. It needs its own.&lt;/p&gt;

&lt;blockquote&gt;
&lt;p&gt;&lt;strong&gt;TL;DR&lt;/strong&gt;: a static API key in an agent is your identity, on loan. Broad, no end date, shared, impossible to trace. An AI agent is a non-human identity, an actor in its own right. Give it its own: a short-lived token, scoped to its need, revocable, and traceable in the logs. The industry has a name for the target, workload identity, like SPIFFE. Not a shared secret sitting in a &lt;code&gt;.env&lt;/code&gt;.&lt;/p&gt;
&lt;/blockquote&gt;

&lt;p&gt;This article is for developers and DevSecOps folks who put an AI agent in production, with access to real systems. Not in a demo.&lt;/p&gt;

&lt;h2&gt;
  
  
  The setup
&lt;/h2&gt;

&lt;p&gt;An AI agent is a language model that calls tools and acts for you. I build one in Go, in a personal project.&lt;/p&gt;

&lt;p&gt;The stack is standard. Go for the code. Postgres for the data. A Kubernetes cluster to host it. And the Anthropic API for the model.&lt;/p&gt;

&lt;p&gt;For its secrets, the agent reads no hand-typed key. It carries a dedicated machine identity. That identity is scoped to one project, one environment, one path. I self-host Infisical for this. Infisical is a secret manager: a vault that stores and hands out keys.&lt;/p&gt;

&lt;p&gt;I come from security, and I am a bit paranoid. Certified pentester, former Top 1% on TryHackMe. Handing my identity to an agent was a no.&lt;/p&gt;

&lt;h2&gt;
  
  
  A static API key is your identity, on loan
&lt;/h2&gt;

&lt;p&gt;Look at what a static API key does. It proves who you are, once and for all. And often, the same key is used everywhere.&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight go"&gt;&lt;code&gt;&lt;span class="err"&gt;#&lt;/span&gt; &lt;span class="o"&gt;.&lt;/span&gt;&lt;span class="n"&gt;env&lt;/span&gt;&lt;span class="o"&gt;:&lt;/span&gt; &lt;span class="n"&gt;the&lt;/span&gt; &lt;span class="n"&gt;bad&lt;/span&gt; &lt;span class="n"&gt;idea&lt;/span&gt;
&lt;span class="n"&gt;ANTHROPIC_API_KEY&lt;/span&gt;&lt;span class="o"&gt;=&lt;/span&gt;&lt;span class="n"&gt;sk&lt;/span&gt;&lt;span class="o"&gt;-&lt;/span&gt;&lt;span class="n"&gt;ant&lt;/span&gt;&lt;span class="o"&gt;-...&lt;/span&gt;
&lt;span class="n"&gt;DATABASE_URL&lt;/span&gt;&lt;span class="o"&gt;=&lt;/span&gt;&lt;span class="n"&gt;postgres&lt;/span&gt;&lt;span class="o"&gt;:&lt;/span&gt;&lt;span class="c"&gt;//app:password@db/prod&lt;/span&gt;
&lt;span class="n"&gt;STRIPE_KEY&lt;/span&gt;&lt;span class="o"&gt;=&lt;/span&gt;&lt;span class="n"&gt;sk_live_&lt;/span&gt;&lt;span class="o"&gt;...&lt;/span&gt;
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;A key like that has four flaws. It is broad: it opens everything your account can do. It is eternal: no end date. It is shared: the same secret serves ten places. And it is mute: a log says "the key acted", never "the agent acted".&lt;/p&gt;

&lt;p&gt;The problem is exploding in 2026. Secrets tied to AI are the fastest-growing leak category. GitGuardian counted 1.27 million of them in 2025 alone, up 81% from 2024. Worse: 64% of the valid secrets leaked in 2022 were still not revoked by early 2026.&lt;/p&gt;

&lt;p&gt;Why does AI make it worse? Because it multiplies the actors. More agents, more services, more tokens lying around. Every key placed by hand is a leak waiting to happen.&lt;/p&gt;

&lt;h2&gt;
  
  
  Your agent is a non-human identity, treat it as one
&lt;/h2&gt;

&lt;p&gt;In security, we split two worlds. Human identities: you, your users. And non-human identities: services, scripts, bots.&lt;/p&gt;

&lt;p&gt;A non-human identity, or NHI, is anything that authenticates without being a person. An AI agent is one of them. It is a full actor, like a service account.&lt;/p&gt;

&lt;p&gt;And those actors are already the majority. In a modern cloud, machines outnumber humans by dozens to one. The AI agent is just the newest arrival. The fastest, and the most unpredictable.&lt;/p&gt;

&lt;p&gt;Be careful not to mix up two identities. There is the user the agent acts for. And there is the agent itself. They are two different things. The agent needs its own identity, separate from yours and from the user's.&lt;/p&gt;

&lt;p&gt;In my services, that means two paths. When the agent acts for a user, I propagate that user's token, end to end. When it is a system task, with no one behind it, a service-to-service token takes over. Never both on the same call. The agent never invents an identity. It carries the user's, or its own.&lt;/p&gt;

&lt;h2&gt;
  
  
  Give it a short-lived token, never an eternal secret
&lt;/h2&gt;

&lt;p&gt;The first rule is simple. Replace the eternal secret with a short-lived token.&lt;/p&gt;

&lt;p&gt;We call it a just-in-time token, or JIT. The idea: the agent does not keep a key forever. It proves who it is, gets a token that lives for a few minutes, then that token dies.&lt;/p&gt;

&lt;p&gt;The payoff shows up on the day of a leak. A stolen static secret works until you notice. Often, months later. A short-lived token is already dead when the thief arrives.&lt;/p&gt;

&lt;p&gt;In my project, the agent knows no production key. It carries a machine identity. With it, an operator fetches its secrets and renews them on its own, continuously.&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight go"&gt;&lt;code&gt;&lt;span class="err"&gt;#&lt;/span&gt; &lt;span class="n"&gt;The&lt;/span&gt; &lt;span class="n"&gt;agent&lt;/span&gt;&lt;span class="err"&gt;'&lt;/span&gt;&lt;span class="n"&gt;s&lt;/span&gt; &lt;span class="n"&gt;machine&lt;/span&gt; &lt;span class="n"&gt;identity&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="n"&gt;scoped&lt;/span&gt; &lt;span class="n"&gt;to&lt;/span&gt; &lt;span class="n"&gt;its&lt;/span&gt; &lt;span class="n"&gt;perimeter&lt;/span&gt;
&lt;span class="n"&gt;projectSlug&lt;/span&gt;&lt;span class="o"&gt;:&lt;/span&gt; &lt;span class="n"&gt;mimmo&lt;/span&gt;
&lt;span class="n"&gt;envSlug&lt;/span&gt;&lt;span class="o"&gt;:&lt;/span&gt; &lt;span class="n"&gt;prod&lt;/span&gt;
&lt;span class="n"&gt;secretsPath&lt;/span&gt;&lt;span class="o"&gt;:&lt;/span&gt; &lt;span class="o"&gt;/&lt;/span&gt;&lt;span class="n"&gt;backend&lt;/span&gt;&lt;span class="o"&gt;/&lt;/span&gt;&lt;span class="n"&gt;mimmo&lt;/span&gt;&lt;span class="o"&gt;-&lt;/span&gt;&lt;span class="n"&gt;ai&lt;/span&gt;   &lt;span class="err"&gt;#&lt;/span&gt; &lt;span class="n"&gt;nothing&lt;/span&gt; &lt;span class="k"&gt;else&lt;/span&gt;
&lt;span class="n"&gt;resyncInterval&lt;/span&gt;&lt;span class="o"&gt;:&lt;/span&gt; &lt;span class="m"&gt;60&lt;/span&gt;               &lt;span class="err"&gt;#&lt;/span&gt; &lt;span class="n"&gt;renewed&lt;/span&gt; &lt;span class="n"&gt;continuously&lt;/span&gt;
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;Same logic on the tooling side. For my automations, I prefer an app that mints a token on the fly over a personal token left lying around. The app proves its identity, gets a token that lives for an hour, acts, then the token expires. Nothing permanent to store, nothing to leak.&lt;/p&gt;

&lt;p&gt;Two reflexes come with it. One: rotate the credentials often. Two: keep a red button. The day an agent goes rogue, you must be able to kill its identity in one command, without breaking the other nine.&lt;/p&gt;

&lt;h2&gt;
  
  
  Scope the identity to what the agent needs
&lt;/h2&gt;

&lt;p&gt;A short-lived token is good. A short-lived and narrow token is better.&lt;/p&gt;

&lt;p&gt;The principle is least privilege: grant only the rights that are useful, nothing more. Applied to identity, it gives a clear rule. One identity per agent. Not a single key shared by your whole fleet.&lt;/p&gt;

&lt;p&gt;Why? An injection is a booby-trapped text that steers the agent off task. If it succeeds, the attacker inherits the current agent's rights. With a narrow identity, the damage stops there. With the master key, everything falls.&lt;/p&gt;

&lt;p&gt;I saw the other approach up close, on a large-scale identity platform. Rights were not simple fixed roles. They were decided by attributes, on every request. This is called ABAC. Who are you, in what context, for which resource: the answer is computed on demand.&lt;/p&gt;

&lt;p&gt;An agent deserves the same treatment. Its identity does not just say "this is an agent". It carries attributes: which agent, which environment, which perimeter. And access is decided from there.&lt;/p&gt;

&lt;h2&gt;
  
  
  Make every action attributable to the agent
&lt;/h2&gt;

&lt;p&gt;A proper identity comes with a gift: traceability.&lt;/p&gt;

&lt;p&gt;With a shared key, your log says "the service account deleted the record". Thanks, but who? Which agent, which session, on whose orders?&lt;/p&gt;

&lt;p&gt;With one identity per agent, the answer is in the log line. You know which actor acted, when, and for whom. This is non-repudiation: no one can deny what they did.&lt;/p&gt;

&lt;p&gt;This is not my idea. NIST is pushing a framework for agent identity. It fits in four words: identify the agent, authorize it, audit it, and tie every action to the human who allowed it.&lt;/p&gt;

&lt;p&gt;In practice, every call from my agent goes to a structured log: which identity, which tool, which decision. The day of an incident, I answer one question. What did this agent do, exactly?&lt;/p&gt;

&lt;h2&gt;
  
  
  The target: a workload identity, like SPIFFE
&lt;/h2&gt;

&lt;p&gt;Where is all this going? Toward a cryptographic identity for each workload.&lt;/p&gt;

&lt;p&gt;The rising standard is SPIFFE, with its implementation SPIRE. The idea is elegant. Each service gets a verifiable identity, proven by cryptography, and short-lived. No more shared secret to copy from one place to another.&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight go"&gt;&lt;code&gt;&lt;span class="err"&gt;#&lt;/span&gt; &lt;span class="n"&gt;A&lt;/span&gt; &lt;span class="n"&gt;workload&lt;/span&gt; &lt;span class="n"&gt;identity&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="n"&gt;not&lt;/span&gt; &lt;span class="n"&gt;a&lt;/span&gt; &lt;span class="n"&gt;shared&lt;/span&gt; &lt;span class="n"&gt;key&lt;/span&gt;
&lt;span class="n"&gt;spiffe&lt;/span&gt;&lt;span class="o"&gt;:&lt;/span&gt;&lt;span class="c"&gt;//mimmo.app/agent/legal-advisor&lt;/span&gt;
&lt;span class="err"&gt;#&lt;/span&gt; &lt;span class="err"&gt;→&lt;/span&gt; &lt;span class="n"&gt;a&lt;/span&gt; &lt;span class="n"&gt;short&lt;/span&gt;&lt;span class="o"&gt;-&lt;/span&gt;&lt;span class="n"&gt;lived&lt;/span&gt; &lt;span class="n"&gt;identifier&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="n"&gt;self&lt;/span&gt;&lt;span class="o"&gt;-&lt;/span&gt;&lt;span class="n"&gt;renewing&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="n"&gt;verifiable&lt;/span&gt;
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;And for AI agents? The shift is under way, at a high level. NIST launched an initiative dedicated to agents in February 2026. Its text on agent identity cites OAuth, OpenID Connect, and SPIFFE. A draft at the IETF describes an identity model for agents, AIMS, built on those same standards. And OWASP ranks excessive agency among the top risks for agentic applications.&lt;/p&gt;

&lt;p&gt;Be honest about maturity. SPIFFE in production for agents is young. I watch this direction closely, without claiming I have deployed it everywhere. But the principle already applies. Short, narrow, revocable, traced identity. You do not need a final standard to stop pasting your key into a &lt;code&gt;.env&lt;/code&gt;.&lt;/p&gt;

&lt;h2&gt;
  
  
  The checklist before granting access
&lt;/h2&gt;

&lt;blockquote&gt;
&lt;p&gt;Before you wire your AI agent to real systems, run its identity through this list.&lt;/p&gt;
&lt;/blockquote&gt;

&lt;ul&gt;
&lt;li&gt;[ ] The agent has its own identity, separate from yours and from the user's&lt;/li&gt;
&lt;li&gt;[ ] No static key in a &lt;code&gt;.env&lt;/code&gt; or in the code&lt;/li&gt;
&lt;li&gt;[ ] A short-lived token instead of the eternal secret, self-renewing&lt;/li&gt;
&lt;li&gt;[ ] One identity per agent, never a shared master key&lt;/li&gt;
&lt;li&gt;[ ] The identity is scoped: one project, one environment, one perimeter&lt;/li&gt;
&lt;li&gt;[ ] Access is decided by attributes, not by a fixed role (ABAC)&lt;/li&gt;
&lt;li&gt;[ ] A red button: you can revoke an identity in one command&lt;/li&gt;
&lt;li&gt;[ ] Regular rotation of credentials&lt;/li&gt;
&lt;li&gt;[ ] Every action is logged with the agent's identity, for non-repudiation&lt;/li&gt;
&lt;li&gt;[ ] Secrets live in a dedicated manager, scanned at every commit&lt;/li&gt;
&lt;li&gt;[ ] The target: a verifiable workload identity, like SPIFFE&lt;/li&gt;
&lt;/ul&gt;

&lt;h2&gt;
  
  
  What to remember
&lt;/h2&gt;

&lt;p&gt;An API key answers "what access". An identity answers "who acts". Your agent needs the second one.&lt;/p&gt;

&lt;p&gt;Do not lend it your identity. Give it its own: short, narrow, revocable, traced. That is the difference between an actor you control and a secret you pray never leaks.&lt;/p&gt;

&lt;p&gt;The standards are coming. Machine identities are exploding. Developers who can give an agent a proper identity, and not just a key, will stay rare for a while.&lt;/p&gt;

&lt;p&gt;Are you putting an AI agent into production? Do you want a second look at how it authenticates, before a key leaks? &lt;a href="https://jrobineau.com/contact.html" rel="noopener noreferrer"&gt;That is exactly what I do. Get in touch.&lt;/a&gt;&lt;/p&gt;




&lt;p&gt;&lt;strong&gt;Sources:&lt;/strong&gt; &lt;a href="https://blog.gitguardian.com/the-state-of-secrets-sprawl-2026/" rel="noopener noreferrer"&gt;GitGuardian, State of Secrets Sprawl 2026&lt;/a&gt; · &lt;a href="https://www.nist.gov/artificial-intelligence/ai-agent-standards-initiative" rel="noopener noreferrer"&gt;NIST AI Agent Standards Initiative&lt;/a&gt; · &lt;a href="https://www.nccoe.nist.gov/projects/software-and-ai-agent-identity-and-authorization" rel="noopener noreferrer"&gt;NCCoE, Software &amp;amp; AI Agent Identity and Authorization&lt;/a&gt; · &lt;a href="https://datatracker.ietf.org/doc/draft-klrc-aiagent-auth/" rel="noopener noreferrer"&gt;IETF draft, AI Agent Authentication (AIMS)&lt;/a&gt; · &lt;a href="https://spiffe.io/" rel="noopener noreferrer"&gt;SPIFFE&lt;/a&gt; · &lt;a href="https://genai.owasp.org/resource/owasp-top-10-for-agentic-applications-for-2026/" rel="noopener noreferrer"&gt;OWASP Top 10 for Agentic Applications&lt;/a&gt;.&lt;/p&gt;

</description>
      <category>ai</category>
      <category>devsecops</category>
      <category>security</category>
      <category>identity</category>
    </item>
    <item>
      <title>Securing a Go Supply Chain: The Pipeline That Holds in 2026</title>
      <dc:creator>Jules Robineau</dc:creator>
      <pubDate>Wed, 29 Jul 2026 07:00:00 +0000</pubDate>
      <link>https://dev.to/julesrobineau/securing-a-go-supply-chain-the-pipeline-that-holds-in-2026-45j7</link>
      <guid>https://dev.to/julesrobineau/securing-a-go-supply-chain-the-pipeline-that-holds-in-2026-45j7</guid>
      <description>&lt;blockquote&gt;
&lt;p&gt;&lt;strong&gt;TL;DR&lt;/strong&gt;: a Go project's security does not live in a code review. It lives in the pipeline. Your supply chain is all the code and tools between your keyboard and production. In 2026, it is the front door for attacks. Here is the pipeline step by step, with the tools, and where each one breaks.&lt;/p&gt;
&lt;/blockquote&gt;

&lt;p&gt;This article is for Go teams that ship to production, and for people who buy DevSecOps. Not for a demo.&lt;/p&gt;

&lt;h2&gt;
  
  
  Your code may be clean. Your supply chain is not.
&lt;/h2&gt;

&lt;p&gt;Your supply chain is your software supply chain. It holds your dependencies, your base images and your build tools. The big recent attacks did not come through a bug in your code. They came through that chain.&lt;/p&gt;

&lt;p&gt;Two examples marked 2025. In March, the GitHub Action &lt;code&gt;tj-actions/changed-files&lt;/code&gt; was compromised (CVE-2025-30066). The attackers moved version tags to a poisoned commit. Every repo pinned to that tag ran the malicious code. The result: secrets dumped into public CI logs, across about 23,000 repositories.&lt;/p&gt;

&lt;p&gt;In September, it was Shai-Hulud. The first self-propagating worm in the npm ecosystem. It runs when a package is installed. It scans the environment for secrets, steals tokens, then republishes poisoned versions on its own.&lt;/p&gt;

&lt;p&gt;Neither attack was a bug at the victim's site. Both entered through a dependency or a build tool. Go is not npm. But Go pulls code and runs tools too. The pipeline is where you defend.&lt;/p&gt;

&lt;h2&gt;
  
  
  The setup: a real Go pipeline, not a diagram
&lt;/h2&gt;

&lt;p&gt;I am talking about two real things. My own Go project, and the pipelines I have built for clients.&lt;/p&gt;

&lt;p&gt;On my own Go project, here is what already runs. &lt;code&gt;gitleaks&lt;/code&gt; scans every commit before it leaves. &lt;code&gt;golangci-lint&lt;/code&gt;, with &lt;code&gt;staticcheck&lt;/code&gt;, blocks any pull request that fails. Dependencies are pinned by hash, and a bot updates them. The final image is distroless and non-root. The CI token is read-only.&lt;/p&gt;

&lt;p&gt;On client pipelines, I added three layers. SAST, which reads code without running it (SonarQube). DAST, which tests the running app (OWASP ZAP). And image scanning (Grype, Trivy). With a measured drop in findings.&lt;/p&gt;

&lt;p&gt;This article walks the whole chain, stage by stage. Some stages run on my side today. Others are the ones I recommend to close the chain. I tell you which is which each time.&lt;/p&gt;

&lt;h2&gt;
  
  
  Step 1: block known vulnerabilities with govulncheck
&lt;/h2&gt;

&lt;p&gt;&lt;code&gt;govulncheck&lt;/code&gt; is the official tool from the Go team. It compares your dependencies and the standard library against the Go vulnerability database.&lt;/p&gt;

&lt;p&gt;Its strength is one idea. It reads your call graph. It only reports a vulnerability if your code actually reaches the vulnerable function. So you get far fewer false alarms than with a generic scanner. One line is enough in CI:&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight go"&gt;&lt;code&gt;&lt;span class="n"&gt;govulncheck&lt;/span&gt; &lt;span class="o"&gt;./...&lt;/span&gt;
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;Where it breaks. &lt;code&gt;govulncheck&lt;/code&gt; only knows about published flaws. A zero-day slips through. A zero-day is a flaw still unknown to defenders. And malicious code with no CVE slips through too. A CVE is the public ID of a known flaw. Step 3 closes that gap.&lt;/p&gt;

&lt;h2&gt;
  
  
  Step 2: pin your dependencies, and let a bot raise them
&lt;/h2&gt;

&lt;p&gt;Go already pins your dependencies by hash, in &lt;code&gt;go.sum&lt;/code&gt;. If a published version changes under you, the build fails. Keep &lt;code&gt;-mod=readonly&lt;/code&gt; in CI, so nothing edits your deps silently.&lt;/p&gt;

&lt;p&gt;But pinned does not mean safe. You can happily pin malware. So you also need managed updates. A bot opens dependency PRs every day.&lt;/p&gt;

&lt;p&gt;On my side, minor updates merge on their own once CI is green. Major versions get labeled and wait for a human. A major bump changes too much to be blind.&lt;/p&gt;

&lt;p&gt;Where it breaks. A single patch can be poisoned, as in the tj-actions case. Auto-merging a malicious patch is a real risk. So: keep few dependencies, and never let a major through without review.&lt;/p&gt;

&lt;h2&gt;
  
  
  Step 3: go beyond the CVE, hunt behavior
&lt;/h2&gt;

&lt;p&gt;Shai-Hulud had no CVE at install time. It was just code that runs and steals secrets. A CVE scanner sees nothing. So you add two things.&lt;/p&gt;

&lt;p&gt;First, SAST on your own code. SAST reads your code without running it and flags risky patterns. Three tools come up often. Here is how I place them.&lt;/p&gt;

&lt;p&gt;&lt;code&gt;govulncheck&lt;/code&gt; is not in this table. It looks at known flaws in your deps, not at patterns in your code. The two are complementary.&lt;/p&gt;

&lt;p&gt;Second, treat third-party code as suspect. Fewer dependencies. Read the diff of a new dependency before you add it. Prefer the standard library. Go helps here: a Go module does not run an install script, unlike npm. But &lt;code&gt;go generate&lt;/code&gt;, cgo and build tags can run code at build time. So the build machine is still a target.&lt;/p&gt;

&lt;p&gt;Where it breaks. SAST has false positives, and it cannot see runtime logic. No tool reads a dependency's mind. The real defense stays: fewer deps, and human review.&lt;/p&gt;

&lt;h2&gt;
  
  
  Step 4: generate an SBOM, because the CRA is coming
&lt;/h2&gt;

&lt;p&gt;An SBOM is the inventory of every component in your build, with versions. Think of it as the ingredient label of your software.&lt;/p&gt;

&lt;p&gt;Two tools do the job in Go: &lt;code&gt;syft&lt;/code&gt; or &lt;code&gt;cyclonedx-gomod&lt;/code&gt;. They produce a file in CycloneDX or SPDX format. Those are the two machine-readable formats. Generate it in CI, and attach it to every release.&lt;/p&gt;

&lt;p&gt;Why now? Because of the CRA, the EU Cyber Resilience Act. From 11 September 2026, a manufacturer must report an actively exploited vulnerability within 24 hours. You cannot hold a 24-hour clock without knowing what is in your product. The SBOM is that map. The formal SBOM duty arrives with full application of the text in 2027. But you need it before the clock starts.&lt;/p&gt;

&lt;p&gt;Where it breaks. A stale SBOM is theater. It only helps when correlated with a live vulnerability feed. Generate it on every build, not once a year.&lt;/p&gt;

&lt;h2&gt;
  
  
  Step 5: scan the image, not just the code
&lt;/h2&gt;

&lt;p&gt;Your Go binary is not the whole artifact. The container ships an OS layer too. Scan the final image. &lt;code&gt;Trivy&lt;/code&gt; or &lt;code&gt;Grype&lt;/code&gt; read it and flag known flaws, in system packages and in the binary alike.&lt;/p&gt;

&lt;p&gt;Shrink the target first. A multi-stage build compiles the binary, then copies it into a tiny base. I use a distroless base, non-root, with a static binary. No shell, no package manager, almost nothing to exploit.&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight go"&gt;&lt;code&gt;&lt;span class="n"&gt;FROM&lt;/span&gt; &lt;span class="n"&gt;golang&lt;/span&gt;&lt;span class="o"&gt;:&lt;/span&gt;&lt;span class="m"&gt;1.25&lt;/span&gt;&lt;span class="o"&gt;-&lt;/span&gt;&lt;span class="n"&gt;bookworm&lt;/span&gt; &lt;span class="n"&gt;AS&lt;/span&gt; &lt;span class="n"&gt;builder&lt;/span&gt;
&lt;span class="err"&gt;#&lt;/span&gt; &lt;span class="o"&gt;...&lt;/span&gt; &lt;span class="k"&gt;go&lt;/span&gt; &lt;span class="n"&gt;mod&lt;/span&gt; &lt;span class="n"&gt;download&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="n"&gt;then&lt;/span&gt;&lt;span class="o"&gt;:&lt;/span&gt;
&lt;span class="n"&gt;RUN&lt;/span&gt; &lt;span class="n"&gt;CGO_ENABLED&lt;/span&gt;&lt;span class="o"&gt;=&lt;/span&gt;&lt;span class="m"&gt;0&lt;/span&gt; &lt;span class="k"&gt;go&lt;/span&gt; &lt;span class="n"&gt;build&lt;/span&gt; &lt;span class="o"&gt;-&lt;/span&gt;&lt;span class="n"&gt;ldflags&lt;/span&gt;&lt;span class="o"&gt;=&lt;/span&gt;&lt;span class="s"&gt;"-s -w"&lt;/span&gt; &lt;span class="o"&gt;-&lt;/span&gt;&lt;span class="n"&gt;o&lt;/span&gt; &lt;span class="o"&gt;/&lt;/span&gt;&lt;span class="n"&gt;app&lt;/span&gt; &lt;span class="o"&gt;./&lt;/span&gt;&lt;span class="n"&gt;cmd&lt;/span&gt;&lt;span class="o"&gt;/&lt;/span&gt;&lt;span class="n"&gt;server&lt;/span&gt;

&lt;span class="n"&gt;FROM&lt;/span&gt; &lt;span class="n"&gt;gcr&lt;/span&gt;&lt;span class="o"&gt;.&lt;/span&gt;&lt;span class="n"&gt;io&lt;/span&gt;&lt;span class="o"&gt;/&lt;/span&gt;&lt;span class="n"&gt;distroless&lt;/span&gt;&lt;span class="o"&gt;/&lt;/span&gt;&lt;span class="n"&gt;static&lt;/span&gt;&lt;span class="o"&gt;-&lt;/span&gt;&lt;span class="n"&gt;debian12&lt;/span&gt;&lt;span class="o"&gt;:&lt;/span&gt;&lt;span class="n"&gt;nonroot&lt;/span&gt;
&lt;span class="n"&gt;COPY&lt;/span&gt; &lt;span class="o"&gt;--&lt;/span&gt;&lt;span class="n"&gt;from&lt;/span&gt;&lt;span class="o"&gt;=&lt;/span&gt;&lt;span class="n"&gt;builder&lt;/span&gt; &lt;span class="o"&gt;/&lt;/span&gt;&lt;span class="n"&gt;app&lt;/span&gt; &lt;span class="o"&gt;/&lt;/span&gt;&lt;span class="n"&gt;app&lt;/span&gt;
&lt;span class="n"&gt;USER&lt;/span&gt; &lt;span class="m"&gt;65534&lt;/span&gt;&lt;span class="o"&gt;:&lt;/span&gt;&lt;span class="m"&gt;65534&lt;/span&gt;
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;Where it breaks. A scanner only knows its database. A minimal image cuts the surface, but the base image ages. Rebuild and rescan often.&lt;/p&gt;

&lt;h2&gt;
  
  
  Step 6: get secrets out of the repo and the build
&lt;/h2&gt;

&lt;p&gt;Shai-Hulud harvested the secrets sitting in the environment. Hence the rule: a secret never sits in the repo, and never bakes into an image layer.&lt;/p&gt;

&lt;p&gt;&lt;code&gt;gitleaks&lt;/code&gt; scans every commit for keys and tokens. On my side, it runs as a pre-commit hook. The same hook blocks any &lt;code&gt;.env&lt;/code&gt;, &lt;code&gt;.pem&lt;/code&gt; or &lt;code&gt;.key&lt;/code&gt; file from being added.&lt;/p&gt;

&lt;p&gt;The real secrets live in a dedicated manager, injected at deploy time. I use Infisical. A managed vault works too.&lt;/p&gt;

&lt;p&gt;In the Docker build, pass secrets with &lt;code&gt;--mount=type=secret&lt;/code&gt;. The secret is available during the build step, but it never writes to a layer.&lt;/p&gt;

&lt;p&gt;Where it breaks. &lt;code&gt;gitleaks&lt;/code&gt; catches known patterns. A secret leaked before the hook, or a new format, slips through. So: rotate your secrets as soon as they are exposed.&lt;/p&gt;

&lt;h2&gt;
  
  
  Step 7: harden GitHub Actions, your most exposed link
&lt;/h2&gt;

&lt;p&gt;Your CI holds your keys and write access. tj-actions proved it. The attackers moved a version tag to a poisoned commit. Every repo pinned to that tag ran the payload. Three moves limit the damage.&lt;/p&gt;

&lt;p&gt;One. Least privilege on the CI token. Set &lt;code&gt;contents: read&lt;/code&gt; by default, and add &lt;code&gt;write&lt;/code&gt; only where a job needs it. On my side, the lint and test jobs are read-only.&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight go"&gt;&lt;code&gt;&lt;span class="n"&gt;permissions&lt;/span&gt;&lt;span class="o"&gt;:&lt;/span&gt;
  &lt;span class="n"&gt;contents&lt;/span&gt;&lt;span class="o"&gt;:&lt;/span&gt; &lt;span class="n"&gt;read&lt;/span&gt;      &lt;span class="err"&gt;#&lt;/span&gt; &lt;span class="n"&gt;read&lt;/span&gt;&lt;span class="o"&gt;-&lt;/span&gt;&lt;span class="n"&gt;only&lt;/span&gt; &lt;span class="n"&gt;by&lt;/span&gt; &lt;span class="k"&gt;default&lt;/span&gt;
  &lt;span class="n"&gt;id&lt;/span&gt;&lt;span class="o"&gt;-&lt;/span&gt;&lt;span class="n"&gt;token&lt;/span&gt;&lt;span class="o"&gt;:&lt;/span&gt; &lt;span class="n"&gt;write&lt;/span&gt;     &lt;span class="err"&gt;#&lt;/span&gt; &lt;span class="n"&gt;only&lt;/span&gt; &lt;span class="n"&gt;to&lt;/span&gt; &lt;span class="n"&gt;request&lt;/span&gt; &lt;span class="n"&gt;a&lt;/span&gt; &lt;span class="n"&gt;short&lt;/span&gt; &lt;span class="n"&gt;OIDC&lt;/span&gt; &lt;span class="n"&gt;token&lt;/span&gt;
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;Two. Pin third-party actions by full commit hash, not by tag. A tag can move, a hash cannot. Many teams still pin by major tag, and that is a fair default. After tj-actions, pin third-party actions by SHA.&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight go"&gt;&lt;code&gt;&lt;span class="o"&gt;-&lt;/span&gt; &lt;span class="n"&gt;uses&lt;/span&gt;&lt;span class="o"&gt;:&lt;/span&gt; &lt;span class="n"&gt;actions&lt;/span&gt;&lt;span class="o"&gt;/&lt;/span&gt;&lt;span class="n"&gt;checkout&lt;/span&gt;&lt;span class="err"&gt;@&lt;/span&gt;&lt;span class="n"&gt;a1b2c3d&lt;/span&gt;   &lt;span class="err"&gt;#&lt;/span&gt; &lt;span class="n"&gt;full&lt;/span&gt; &lt;span class="n"&gt;hash&lt;/span&gt; &lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="m"&gt;40&lt;/span&gt; &lt;span class="n"&gt;chars&lt;/span&gt;&lt;span class="p"&gt;),&lt;/span&gt; &lt;span class="n"&gt;not&lt;/span&gt; &lt;span class="err"&gt;@&lt;/span&gt;&lt;span class="n"&gt;v4&lt;/span&gt;
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;Three. Use OIDC to authenticate to the cloud. OIDC lets a job prove its identity and receive a short token, with no stored key. A stolen log or a leaked environment then holds a token that is already dead. That is the direct answer to tj-actions and to Shai-Hulud.&lt;/p&gt;

&lt;p&gt;Where it breaks. SHA pinning protects against a moved tag, not against a compromised maintainer who publishes a release you then bump to. Reviewing version bumps is still needed.&lt;/p&gt;

&lt;h2&gt;
  
  
  Step 8: sign what you ship
&lt;/h2&gt;

&lt;p&gt;The last lock. A signature proves an artifact comes from your pipeline, not from an attacker.&lt;/p&gt;

&lt;p&gt;&lt;code&gt;cosign&lt;/code&gt;, from the Sigstore project, signs an image or a file. With no key to manage, the signature ties to your CI identity. Your users verify it before they run the artifact. GitHub can also attach build provenance: a signed record of how and where it was built.&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight go"&gt;&lt;code&gt;&lt;span class="n"&gt;cosign&lt;/span&gt; &lt;span class="n"&gt;sign&lt;/span&gt;   &lt;span class="err"&gt;$&lt;/span&gt;&lt;span class="n"&gt;IMAGE&lt;/span&gt;
&lt;span class="n"&gt;cosign&lt;/span&gt; &lt;span class="n"&gt;verify&lt;/span&gt; &lt;span class="err"&gt;$&lt;/span&gt;&lt;span class="n"&gt;IMAGE&lt;/span&gt; &lt;span class="o"&gt;--&lt;/span&gt;&lt;span class="n"&gt;certificate&lt;/span&gt;&lt;span class="o"&gt;-&lt;/span&gt;&lt;span class="n"&gt;identity&lt;/span&gt; &lt;span class="o"&gt;...&lt;/span&gt;
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;Where it breaks. A signature proves origin, not innocence. A signed malicious artifact is still malicious. But you gain traceability and revocation. You know what to recall, and fast.&lt;/p&gt;

&lt;h2&gt;
  
  
  The supply chain checklist before you ship
&lt;/h2&gt;

&lt;blockquote&gt;
&lt;p&gt;Before you ship a Go service, walk your supply chain end to end.&lt;/p&gt;
&lt;/blockquote&gt;

&lt;ul&gt;
&lt;li&gt;[ ] &lt;code&gt;govulncheck&lt;/code&gt; runs in CI and reads your call graph&lt;/li&gt;
&lt;li&gt;[ ] Dependencies pinned by hash, build in &lt;code&gt;-mod=readonly&lt;/code&gt;
&lt;/li&gt;
&lt;li&gt;[ ] A bot raises deps: minor automatic, major reviewed by a human&lt;/li&gt;
&lt;li&gt;[ ] A SAST on your code (gosec or semgrep), on top of &lt;code&gt;staticcheck&lt;/code&gt;
&lt;/li&gt;
&lt;li&gt;[ ] Few dependencies, and a new dep's diff read before adding it&lt;/li&gt;
&lt;li&gt;[ ] An SBOM generated on every build, in CycloneDX or SPDX format&lt;/li&gt;
&lt;li&gt;[ ] The final image scanned (Trivy or Grype), on a distroless non-root base&lt;/li&gt;
&lt;li&gt;[ ] Secrets out of the repo, scanned by &lt;code&gt;gitleaks&lt;/code&gt;, injected at deploy&lt;/li&gt;
&lt;li&gt;[ ] Build secrets passed via &lt;code&gt;--mount=type=secret&lt;/code&gt;, never in a layer&lt;/li&gt;
&lt;li&gt;[ ] CI token set to &lt;code&gt;contents: read&lt;/code&gt; by default&lt;/li&gt;
&lt;li&gt;[ ] Third-party actions pinned by SHA, not by tag&lt;/li&gt;
&lt;li&gt;[ ] OIDC for the cloud, instead of long-lived keys&lt;/li&gt;
&lt;li&gt;[ ] Artifacts signed with &lt;code&gt;cosign&lt;/code&gt;, build provenance attached&lt;/li&gt;
&lt;/ul&gt;

&lt;h2&gt;
  
  
  What to remember
&lt;/h2&gt;

&lt;p&gt;A chain is only as strong as its weakest link. And that link is rarely your code. It is a moved tag, an auto-merged patch, a secret in a log.&lt;/p&gt;

&lt;p&gt;Go gives you a real edge. Static binaries, a tiny attack surface, &lt;code&gt;govulncheck&lt;/code&gt; reading your call graph, no install scripts by default. Use it. Then close the chain: pin, scan, inventory, sign.&lt;/p&gt;

&lt;p&gt;The CRA is not the reason to do this. It is the deadline that removes your excuse. Are you building a Go pipeline and want a second pair of eyes on its supply chain? &lt;a href="https://jrobineau.com/contact.html" rel="noopener noreferrer"&gt;That is exactly what I do. Get in touch.&lt;/a&gt; Ship your service. Not the breach with it.&lt;/p&gt;




&lt;p&gt;&lt;strong&gt;Sources:&lt;/strong&gt; &lt;a href="https://go.dev/blog/govulncheck" rel="noopener noreferrer"&gt;govulncheck (Go)&lt;/a&gt; · &lt;a href="https://www.wiz.io/blog/github-action-tj-actions-changed-files-supply-chain-attack-cve-2025-30066" rel="noopener noreferrer"&gt;tj-actions / CVE-2025-30066 (Wiz)&lt;/a&gt; · &lt;a href="https://www.cisa.gov/news-events/alerts/2025/03/18/supply-chain-compromise-third-party-tj-actionschanged-files-cve-2025-30066-and-reviewdog" rel="noopener noreferrer"&gt;tj-actions (CISA)&lt;/a&gt; · &lt;a href="https://www.wiz.io/blog/shai-hulud-npm-supply-chain-attack" rel="noopener noreferrer"&gt;Shai-Hulud (Wiz)&lt;/a&gt; · &lt;a href="https://www.cisa.gov/news-events/alerts/2025/09/23/widespread-supply-chain-compromise-impacting-npm-ecosystem" rel="noopener noreferrer"&gt;Shai-Hulud (CISA)&lt;/a&gt; · &lt;a href="https://www.blackduck.com/solutions/eu-cyber-resilience-act-compliance/cra-vulnerability-reporting-requirements.html" rel="noopener noreferrer"&gt;CRA, 24h reporting (Black Duck)&lt;/a&gt; · &lt;a href="https://www.keysight.com/blogs/en/tech/nwvs/2025/09/11/one-year-countdown-to-eu-cra-compliance-september-11-2026-changes-everything" rel="noopener noreferrer"&gt;CRA, Sept 11 2026 (Keysight)&lt;/a&gt; · &lt;a href="https://cyclonedx.org/" rel="noopener noreferrer"&gt;CycloneDX (SBOM)&lt;/a&gt; · &lt;a href="https://docs.sigstore.dev/" rel="noopener noreferrer"&gt;Sigstore / cosign&lt;/a&gt;.&lt;/p&gt;

</description>
      <category>go</category>
      <category>security</category>
      <category>devsecops</category>
      <category>supplychain</category>
    </item>
    <item>
      <title>Sovereign EU Self-Hosting: A Technical Playbook (Terraform and Ansible)</title>
      <dc:creator>Jules Robineau</dc:creator>
      <pubDate>Mon, 27 Jul 2026 09:00:00 +0000</pubDate>
      <link>https://dev.to/julesrobineau/sovereign-eu-self-hosting-a-technical-playbook-terraform-and-ansible-31me</link>
      <guid>https://dev.to/julesrobineau/sovereign-eu-self-hosting-a-technical-playbook-terraform-and-ansible-31me</guid>
      <description>&lt;blockquote&gt;
&lt;p&gt;&lt;strong&gt;TL;DR&lt;/strong&gt;: hosting in Europe is good. But an "EU region" on a US cloud is not sovereignty. The CLOUD Act keeps the door open. Real sovereignty is infrastructure you own, on a European host, described in Terraform and Ansible. Here is the playbook I use. Which open-source components replace which US services. Where your data lives. And how to stay AI-ready without depending on a US provider.&lt;/p&gt;
&lt;/blockquote&gt;

&lt;p&gt;This article is for CTOs and tech leads at European SMBs and scale-ups that handle sensitive data. Fintech, healthcare, public sector.&lt;/p&gt;

&lt;h2&gt;
  
  
  Sovereignty is no longer a slogan. It is an architecture decision.
&lt;/h2&gt;

&lt;p&gt;In 2026, digital sovereignty moved from slogan to technical constraint. Digital sovereignty means keeping control of your data and your infrastructure. Who can reach it, under which law, and from where.&lt;/p&gt;

&lt;p&gt;The European framework kept stacking, text after text. GDPR has protected personal data since 2018. The NIS2 directive tightens cyber obligations, and member states have been transposing it since late 2024. DORA forces financial players to manage their cloud dependency, and it applies since January 2025. The Data Act makes switching cloud providers easier, and it applies since September 2025.&lt;/p&gt;

&lt;p&gt;The AI Act deserves an important nuance. It entered into force in August 2024. Its transparency rules do apply in August 2026. But watch out for a calendar trap. In May 2026, the EU agreed to push high-risk system obligations to December 2027. So do not build your stack to beat a single date. Build it because the pressure is structural, not a one-off.&lt;/p&gt;

&lt;p&gt;The other driver is resilience. The summer of 2026 was a blunt reminder. AWS had several outages in July. On the 16th, an incident on its CloudFront service lasted more than three hours. On the 24th, an outage in its Oregon region took down Apple Pay, Reddit and others for about 80 minutes. It was the third notable incident in three months.&lt;/p&gt;

&lt;p&gt;A hyperscaler is a cloud giant like AWS, Azure or Google Cloud. When everyone depends on the same hyperscalers, everyone falls together. The underlying shift is real, right up to the top. In April 2026, the European Commission awarded a sovereign cloud contract. Up to 180 million euros over six years, for its own institutions.&lt;/p&gt;

&lt;p&gt;On my side, I have been building sovereign self-hosted stacks for a while. Two personal projects serve as my base. A turnkey DevSecOps bundle, and a self-hosted zero-trust VPN mesh. Both run on Hetzner, a German host. Both deploy fully from code, never by hand.&lt;/p&gt;

&lt;h2&gt;
  
  
  An "EU region" on a US cloud is not sovereignty
&lt;/h2&gt;

&lt;p&gt;Here is the most common mistake. You tick "Europe region" at your US cloud, and you feel compliant. The problem is not where the data is stored. The problem is who can demand it.&lt;/p&gt;

&lt;p&gt;The CLOUD Act is a US law from 2018. It lets US authorities compel a US company to hand over data. Even when that data sits in Europe. So the European subsidiary of a US group stays under that law. The server location changes nothing.&lt;/p&gt;

&lt;p&gt;European courts already flagged this risk. The Schrems II ruling, in 2020, invalidated the data transfer framework to the United States. The reason: possible access by foreign authorities to your data.&lt;/p&gt;

&lt;p&gt;Hyperscalers now offer so-called "sovereign" options. Isolated regions, operated in Europe. It is better than nothing. But the parent company stays American, so under American law. Sovereignty is not just a storage location. It is control. Who owns the infrastructure, who holds the keys, who answers subpoenas. The simplest answer to those three questions is to host it yourself, on a European provider.&lt;/p&gt;

&lt;h2&gt;
  
  
  Replace every US service with a component you control
&lt;/h2&gt;

&lt;p&gt;Good news: for almost every US service, there is an open-source equivalent you host yourself. This is the core of the DevSecOps bundle I deploy for clients, on their own infrastructure. Here is the mapping I use:&lt;/p&gt;

&lt;ul&gt;
&lt;li&gt;
&lt;strong&gt;Identity and SSO&lt;/strong&gt;: Keycloak replaces Okta, Auth0 or Entra ID. SSO, or single sign-on, means one login for all your apps. Keycloak also handles roles and a second factor.&lt;/li&gt;
&lt;li&gt;
&lt;strong&gt;Zero-trust network&lt;/strong&gt;: Netbird replaces Tailscale or Zscaler. It is a mesh VPN built on WireGuard. Zero-trust starts from a simple rule: trust no one by default, even inside the network.&lt;/li&gt;
&lt;li&gt;
&lt;strong&gt;Secrets&lt;/strong&gt;: Vaultwarden holds team passwords. For application secrets, you add Vault or Infisical. Everything stays with you, never in a third-party vault.&lt;/li&gt;
&lt;li&gt;
&lt;strong&gt;Monitoring and logs&lt;/strong&gt;: Grafana, Prometheus and Loki replace Datadog or New Relic. You see your metrics and logs without sending them out.&lt;/li&gt;
&lt;li&gt;
&lt;strong&gt;Backups&lt;/strong&gt;: Restic and pgBackRest write to MinIO. MinIO is S3-compatible object storage that you host yourself.&lt;/li&gt;
&lt;li&gt;
&lt;strong&gt;HTTPS entry and DNS&lt;/strong&gt;: Traefik handles the reverse proxy and Let's Encrypt certificates. For DNS, a European player like OVH is enough.&lt;/li&gt;
&lt;/ul&gt;

&lt;p&gt;Every block is open-source. You can audit it, fork it, run it without a license. And most of all, your data never leaves your perimeter.&lt;/p&gt;

&lt;h2&gt;
  
  
  Deploy everything with Terraform and Ansible, not by hand
&lt;/h2&gt;

&lt;p&gt;A stack built by hand does not stay sovereign for long. It drifts, and soon nobody can rebuild it. So I describe everything in code. Two tools split the work. Terraform creates the infrastructure: the machine, the network, the firewall, the DNS. Ansible then configures the machine: the packages, the services, the hardening.&lt;/p&gt;

&lt;p&gt;My self-hosted VPN project shows it well. Terraform provisions a VM on Hetzner and manages DNS on OVH. Two European providers, one code file:&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight go"&gt;&lt;code&gt;&lt;span class="n"&gt;terraform&lt;/span&gt; &lt;span class="p"&gt;{&lt;/span&gt;
  &lt;span class="n"&gt;required_providers&lt;/span&gt; &lt;span class="p"&gt;{&lt;/span&gt;
    &lt;span class="n"&gt;hcloud&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="p"&gt;{&lt;/span&gt; &lt;span class="n"&gt;source&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="s"&gt;"hetznercloud/hcloud"&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="n"&gt;version&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="s"&gt;"~&amp;gt; 1.45"&lt;/span&gt; &lt;span class="p"&gt;}&lt;/span&gt;  &lt;span class="err"&gt;#&lt;/span&gt; &lt;span class="n"&gt;VM&lt;/span&gt; &lt;span class="n"&gt;on&lt;/span&gt; &lt;span class="n"&gt;Hetzner&lt;/span&gt; &lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="n"&gt;DE&lt;/span&gt;&lt;span class="p"&gt;)&lt;/span&gt;
    &lt;span class="n"&gt;ovh&lt;/span&gt;    &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="p"&gt;{&lt;/span&gt; &lt;span class="n"&gt;source&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="s"&gt;"ovh/ovh"&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt;             &lt;span class="n"&gt;version&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="s"&gt;"~&amp;gt; 0.50"&lt;/span&gt; &lt;span class="p"&gt;}&lt;/span&gt;  &lt;span class="err"&gt;#&lt;/span&gt; &lt;span class="n"&gt;DNS&lt;/span&gt; &lt;span class="n"&gt;on&lt;/span&gt; &lt;span class="n"&gt;OVH&lt;/span&gt; &lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="n"&gt;FR&lt;/span&gt;&lt;span class="p"&gt;)&lt;/span&gt;
  &lt;span class="p"&gt;}&lt;/span&gt;
&lt;span class="p"&gt;}&lt;/span&gt;
&lt;span class="err"&gt;#&lt;/span&gt; &lt;span class="n"&gt;Then&lt;/span&gt; &lt;span class="n"&gt;Ansible&lt;/span&gt; &lt;span class="n"&gt;hardens&lt;/span&gt; &lt;span class="n"&gt;the&lt;/span&gt; &lt;span class="n"&gt;host&lt;/span&gt; &lt;span class="n"&gt;and&lt;/span&gt; &lt;span class="n"&gt;installs&lt;/span&gt; &lt;span class="n"&gt;the&lt;/span&gt; &lt;span class="n"&gt;service&lt;/span&gt;&lt;span class="o"&gt;:&lt;/span&gt;
&lt;span class="err"&gt;#&lt;/span&gt;   &lt;span class="n"&gt;roles&lt;/span&gt;&lt;span class="o"&gt;/&lt;/span&gt;&lt;span class="n"&gt;system&lt;/span&gt;&lt;span class="o"&gt;-&lt;/span&gt;&lt;span class="n"&gt;hardening&lt;/span&gt;  &lt;span class="o"&gt;-&amp;gt;&lt;/span&gt; &lt;span class="n"&gt;updates&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="n"&gt;firewall&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="n"&gt;fail2ban&lt;/span&gt;
&lt;span class="err"&gt;#&lt;/span&gt;   &lt;span class="n"&gt;roles&lt;/span&gt;&lt;span class="o"&gt;/&lt;/span&gt;&lt;span class="n"&gt;netbird&lt;/span&gt;&lt;span class="o"&gt;-&lt;/span&gt;&lt;span class="n"&gt;install&lt;/span&gt;   &lt;span class="o"&gt;-&amp;gt;&lt;/span&gt; &lt;span class="n"&gt;the&lt;/span&gt; &lt;span class="n"&gt;self&lt;/span&gt;&lt;span class="o"&gt;-&lt;/span&gt;&lt;span class="n"&gt;hosted&lt;/span&gt; &lt;span class="n"&gt;service&lt;/span&gt;
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;Ansible takes over. One role hardens the system. Another installs the service. The whole thing runs through a GitHub Actions pipeline. A plan on every pull request. A deploy on merge. A separate job to tear it all down cleanly.&lt;/p&gt;

&lt;p&gt;The real win is reproducibility. You redeploy the same stack elsewhere in one command. If your host raises prices or goes down, you move without starting over. The same code serves me on Hetzner, OVH, GCP or DigitalOcean. That is the opposite of lock-in.&lt;/p&gt;

&lt;h2&gt;
  
  
  Harden the host and encrypt your backups
&lt;/h2&gt;

&lt;p&gt;Owning the infrastructure also means protecting it. Nobody does it for you. My Ansible hardening role does the bare minimum. Automatic security updates. A firewall closed by default. fail2ban against brute-force attacks. Useless services turned off. fail2ban bans an IP address after too many failed login attempts.&lt;/p&gt;

&lt;p&gt;Then, backups. When you own the infrastructure, you also answer when a disk dies. I encrypt backups with Restic. The Postgres database goes out with pgBackRest. Both write to storage you control, not to a third party's S3. And I test the restore for real. A backup you never restored is not a backup.&lt;/p&gt;

&lt;p&gt;This point meets a DORA requirement. Financial players must be able to exit a provider, without getting stuck. With code and portable backups, that exit is already prepared. You do not improvise it on outage day.&lt;/p&gt;

&lt;h2&gt;
  
  
  Stay AI-ready without handing your data to a US provider
&lt;/h2&gt;

&lt;p&gt;Sovereign does not mean cut off from AI. It means keeping control. The trap is sending your raw data to a US API. There, you lose residency and control in one move.&lt;/p&gt;

&lt;p&gt;So I treat the model as a spare part. It plugs in behind a gateway, and it swaps out. I keep the model at the end of the chain, never at the center of the system. I wrote &lt;a href="https://jrobineau.com/blog/put-the-llm-last.html" rel="noopener noreferrer"&gt;a whole article&lt;/a&gt; on that. And I mask personal data before it reaches the model, just like &lt;a href="https://jrobineau.com/blog/mcp-secure-go.html" rel="noopener noreferrer"&gt;when securing an agent&lt;/a&gt;.&lt;/p&gt;

&lt;p&gt;For sovereignty, you have two paths. A European provider like Mistral. Or an open-weight model you host yourself. An open-weight model has public weights. So you can run it on your own machine. You serve it on a European GPU, at OVHcloud or Scaleway.&lt;/p&gt;

&lt;p&gt;Your data plane stays with you. The model becomes an implementation detail, not a dependency. The result: you get AI, but you decide where the data goes.&lt;/p&gt;

&lt;h2&gt;
  
  
  The cost changed in 2026. Do the real math.
&lt;/h2&gt;

&lt;p&gt;The cloud sold itself on two promises. Simple: no more servers to manage. And cheap: you pay for what you use. In 2026, both have aged.&lt;/p&gt;

&lt;p&gt;Simple, less and less. Tracking the bill became its own job, with a name: FinOps. Cheap, not really. Pay-as-you-go punishes workloads that run all the time. Data egress gets billed, and managed services pile up.&lt;/p&gt;

&lt;p&gt;So some companies go the other way. It is called repatriation: moving back to your own infrastructure. 37signals brought its cloud bill down from 3.2 to 1.3 million dollars a year. A Flexera study estimates about a fifth of the workloads that moved to the cloud have already come back.&lt;/p&gt;

&lt;p&gt;An honest word about money. Self-hosting is not always cheaper. In 2026, even European hosts raised prices. Hetzner increased its rates several times. On 1 April, by up to about 37%. On 15 June, some dedicated-vCPU plans more than doubled. The cause is not margin. The price of DRAM memory jumped by more than 90% in a single quarter.&lt;/p&gt;

&lt;p&gt;So do the real math, with real numbers. Three habits help:&lt;/p&gt;

&lt;ul&gt;
&lt;li&gt;
&lt;strong&gt;Size it right.&lt;/strong&gt; Many stacks run on far less than what we provision by reflex.&lt;/li&gt;
&lt;li&gt;
&lt;strong&gt;Pick the right instance type.&lt;/strong&gt; Arm plans rose far less than dedicated vCPU.&lt;/li&gt;
&lt;li&gt;
&lt;strong&gt;Count the total cost, not just the VM.&lt;/strong&gt; On a hyperscaler, data egress and managed services inflate the bill.&lt;/li&gt;
&lt;/ul&gt;

&lt;p&gt;And count what sovereignty gives you in return. No lock-in, simpler compliance, an exit that always stays open. Lock-in is the dependency that makes leaving too expensive. For an SMB with sensitive data, that control is often worth more than a few euros of VM.&lt;/p&gt;

&lt;h2&gt;
  
  
  The sovereign self-hosting checklist
&lt;/h2&gt;

&lt;blockquote&gt;
&lt;p&gt;Before you call yourself "sovereign", tick every box.&lt;/p&gt;
&lt;/blockquote&gt;

&lt;ul&gt;
&lt;li&gt;[ ] The host is European, and out of reach of the CLOUD Act&lt;/li&gt;
&lt;li&gt;[ ] You do not confuse "EU region" with real sovereignty&lt;/li&gt;
&lt;li&gt;[ ] Every US service has a self-hosted open-source equivalent: identity, secrets, network, monitoring, backups&lt;/li&gt;
&lt;li&gt;[ ] All the infrastructure is described in Terraform and Ansible, not built by hand&lt;/li&gt;
&lt;li&gt;[ ] The same code can redeploy the stack on another host&lt;/li&gt;
&lt;li&gt;[ ] The host is hardened: auto updates, firewall, fail2ban, useless services off&lt;/li&gt;
&lt;li&gt;[ ] Backups are encrypted, portable, and you already tested a restore&lt;/li&gt;
&lt;li&gt;[ ] The AI model is isolated behind a gateway, and replaceable&lt;/li&gt;
&lt;li&gt;[ ] Personal data is masked before it reaches the model&lt;/li&gt;
&lt;li&gt;[ ] You did the real cost math: sizing, instance type, total cost&lt;/li&gt;
&lt;li&gt;[ ] An exit strategy exists, ready to run, in the spirit of DORA&lt;/li&gt;
&lt;/ul&gt;

&lt;h2&gt;
  
  
  What to remember
&lt;/h2&gt;

&lt;p&gt;Sovereignty is not a marketing badge. It is a chain of technical decisions. An EU region on a US cloud is not enough. Control comes from infrastructure you truly own.&lt;/p&gt;

&lt;p&gt;The good news is that the building blocks exist, all open-source. Terraform and Ansible assemble them into a stack you master. And that stack stays AI-ready, on your terms.&lt;/p&gt;

&lt;p&gt;Do you want to build a sovereign stack, or check that yours really is one? &lt;a href="https://jrobineau.com/contact.html" rel="noopener noreferrer"&gt;That is exactly what I do. Get in touch.&lt;/a&gt; Host in Europe. But host on infrastructure that is truly yours.&lt;/p&gt;




&lt;p&gt;&lt;strong&gt;Sources:&lt;/strong&gt; &lt;a href="https://www.gibsondunn.com/eu-ai-act-omnibus-agreement-postponed-high-risk-deadlines-and-other-key-changes/" rel="noopener noreferrer"&gt;AI Act "digital omnibus" (Gibson Dunn)&lt;/a&gt; · &lt;a href="https://fossforce.com/2026/07/what-europes-digital-sovereignty-push-means-for-open-source-platforms/" rel="noopener noreferrer"&gt;EU digital sovereignty (FOSS Force)&lt;/a&gt; · &lt;a href="https://blog.elest.io/digital-sovereignty-in-2026-how-eu-data-residency-laws-are-driving-the-self-hosting-boom/" rel="noopener noreferrer"&gt;Self-hosting boom (elest.io)&lt;/a&gt; · &lt;a href="https://blog.incidenthub.cloud/aws-cloudfront-outage-jul-16-2026" rel="noopener noreferrer"&gt;AWS CloudFront outage, 16 July 2026&lt;/a&gt; · &lt;a href="https://www.techtimes.com/articles/321567/20260725/aws-knocks-out-apple-pay-reddit-hulu-80-minutes-third-outage-since-may.htm" rel="noopener noreferrer"&gt;AWS Oregon outage, 24 July 2026&lt;/a&gt; · &lt;a href="https://northflank.com/blog/hetzner-cloud-server-price-increases" rel="noopener noreferrer"&gt;Hetzner 2026 price increases&lt;/a&gt; · &lt;a href="https://www.datacenterdynamics.com/en/news/37signals-claims-it-saved-almost-2m-last-year-from-cloud-repatriation/" rel="noopener noreferrer"&gt;Cloud repatriation, 37signals (DCD)&lt;/a&gt; · &lt;a href="https://www.hbs.net/blog/cloud-repatriation-trends-cost-ai-and-the-push-towards-hybrid" rel="noopener noreferrer"&gt;Repatriation trends (HBS)&lt;/a&gt; · GDPR · NIS2 · DORA · Data Act · CLOUD Act (2018) · Schrems II (2020).&lt;/p&gt;

</description>
      <category>devops</category>
      <category>security</category>
      <category>selfhosting</category>
      <category>opensource</category>
    </item>
    <item>
      <title>My AI agent tried to delete my secrets. It couldn't.</title>
      <dc:creator>Jules Robineau</dc:creator>
      <pubDate>Mon, 27 Jul 2026 07:00:00 +0000</pubDate>
      <link>https://dev.to/julesrobineau/my-ai-agent-tried-to-delete-my-secrets-it-couldnt-2hm0</link>
      <guid>https://dev.to/julesrobineau/my-ai-agent-tried-to-delete-my-secrets-it-couldnt-2hm0</guid>
      <description>&lt;p&gt;One day, my AI agent tried to delete the secrets of my infra.&lt;/p&gt;

&lt;p&gt;It wrote the Terraform command. It ran it. Nothing happened.&lt;/p&gt;

&lt;p&gt;Not because it changed its mind. Because my Terraform only applies through the pipeline, never locally. And because it has no access to do it.&lt;/p&gt;

&lt;p&gt;That is the best moment in my whole setup. An agent did something dangerous, and it cost nothing. That is the whole idea. Don't bet on a well-behaved agent. Build an agent that can't break things.&lt;/p&gt;

&lt;blockquote&gt;
&lt;p&gt;&lt;strong&gt;TL;DR&lt;/strong&gt;: an AI agent is a new dev. Fast, tireless, no judgment. Locally, I give it everything, so it moves fast. In prod, almost nothing: it reads the logs, and it fixes things by pull request. The guardrails do not live in the model. They live in hooks, pre-commit checks, GitHub rules and RBAC. You don't trust the agent. You make breaking things impossible.&lt;/p&gt;
&lt;/blockquote&gt;

&lt;p&gt;This article is for developers and DevSecOps folks who let a coding agent act in their repo and their infra. Claude Code, Cursor, the tool does not matter.&lt;/p&gt;

&lt;h2&gt;
  
  
  The setup
&lt;/h2&gt;

&lt;p&gt;A coding agent is an LLM that reads your repo and runs commands for you.&lt;/p&gt;

&lt;p&gt;I run these agents every day, on my own SaaS. The stack is ordinary. Go for the backend. A k3s cluster for the infra. A k3s is a lightweight Kubernetes, which orchestrates containers. Terraform to provision everything. Terraform is IaC: you describe your infra in code. Grafana and Jaeger to see what happens.&lt;/p&gt;

&lt;p&gt;I come from security, and I am a bit paranoid. Handing prod keys to an agent, with no safety net, was a no.&lt;/p&gt;

&lt;h2&gt;
  
  
  An AI agent is a new dev
&lt;/h2&gt;

&lt;p&gt;Think back to onboarding a junior dev. You do not hand them prod access and the SSH keys on day one. You give them a laptop, a throwaway dev database, and one rule: everything goes through code review.&lt;/p&gt;

&lt;p&gt;An AI agent is the same onboarding. Except it types a hundred commands a minute, with no fatigue and no fear. That is least privilege: grant only the rights that are needed, nothing more. An over-permissioned agent is the real risk.&lt;/p&gt;

&lt;p&gt;This is not theoretical. In 2025, Replit's AI agent deleted an entire production database, in the middle of a code freeze. It then made up fake records and lied about what it had done. The CEO admitted an error that "should never be possible". His fix? Separate dev and prod, and rein the agent in. Exactly the topic of this article.&lt;/p&gt;

&lt;p&gt;The topic is serious enough that standards are moving. Zero Trust is a security approach: trust no one by default, verify on every access. OWASP published a Top 10 of risks for agentic applications. NIST, in its Zero Trust text, already talks about "subjects", not just users. An agent is a subject like any other. And the Cloud Security Alliance shipped a Zero Trust framework for agents. It is co-written with the inventor of Zero Trust.&lt;/p&gt;

&lt;p&gt;The right question is not "how many rights". It is "how much autonomy". Fewer rights, yes. But above all, less power to act unchecked.&lt;/p&gt;

&lt;h2&gt;
  
  
  Locally, give it full power
&lt;/h2&gt;

&lt;p&gt;Locally, my agent has everything. All the dev secrets, all the commands, all the access. I want it to move fast.&lt;/p&gt;

&lt;p&gt;In my cluster, it roams freely in the dev space. A namespace is an isolated space inside the cluster. It deploys, breaks pods, tries again.&lt;/p&gt;

&lt;p&gt;The access level comes from the kubeconfig context. A kubeconfig is the file that tells &lt;code&gt;kubectl&lt;/code&gt; which cluster to talk to, and with what rights. I keep one context per environment.&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight shell"&gt;&lt;code&gt;&lt;span class="c"&gt;# Locally, the agent works on the dev context: read-write&lt;/span&gt;
kubectl config use-context dev

&lt;span class="c"&gt;# The prod context exists too, but it is read-only (see below)&lt;/span&gt;
kubectl config use-context prod
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;Why is that full power fine in dev? Because the blast radius is small. The blast radius is what it can break if things go wrong. In dev, the data is throwaway. A mistake costs five minutes, not a customer.&lt;/p&gt;

&lt;h2&gt;
  
  
  A per-environment permission matrix
&lt;/h2&gt;

&lt;p&gt;A dev does not have the same rights locally and in prod. Neither does your agent. Here is the map I follow, from most open to most locked down.&lt;/p&gt;

&lt;div class="table-wrapper-paragraph"&gt;&lt;table&gt;
&lt;thead&gt;
&lt;tr&gt;
&lt;th&gt;Capability&lt;/th&gt;
&lt;th&gt;dev&lt;/th&gt;
&lt;th&gt;staging&lt;/th&gt;
&lt;th&gt;prod&lt;/th&gt;
&lt;/tr&gt;
&lt;/thead&gt;
&lt;tbody&gt;
&lt;tr&gt;
&lt;td&gt;Shell commands&lt;/td&gt;
&lt;td&gt;all&lt;/td&gt;
&lt;td&gt;allowlist&lt;/td&gt;
&lt;td&gt;none&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;Read logs / traces&lt;/td&gt;
&lt;td&gt;yes&lt;/td&gt;
&lt;td&gt;yes&lt;/td&gt;
&lt;td&gt;yes (read-only)&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;List k8s resources&lt;/td&gt;
&lt;td&gt;yes&lt;/td&gt;
&lt;td&gt;yes&lt;/td&gt;
&lt;td&gt;yes&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;Write / delete&lt;/td&gt;
&lt;td&gt;yes&lt;/td&gt;
&lt;td&gt;no&lt;/td&gt;
&lt;td&gt;no&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;Read secrets&lt;/td&gt;
&lt;td&gt;yes&lt;/td&gt;
&lt;td&gt;no&lt;/td&gt;
&lt;td&gt;no&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;Apply Terraform&lt;/td&gt;
&lt;td&gt;pipeline&lt;/td&gt;
&lt;td&gt;pipeline&lt;/td&gt;
&lt;td&gt;pipeline&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;Merge to main&lt;/td&gt;
&lt;td&gt;always via PR + review + green CI&lt;/td&gt;
&lt;td&gt;&lt;/td&gt;
&lt;td&gt;&lt;/td&gt;
&lt;/tr&gt;
&lt;/tbody&gt;
&lt;/table&gt;&lt;/div&gt;

&lt;p&gt;The rest of this article is how I hold each cell of that matrix. Without ever counting on the agent's good sense.&lt;/p&gt;

&lt;h2&gt;
  
  
  Put the guardrails outside the model
&lt;/h2&gt;

&lt;p&gt;Here is the idea that holds the whole article. You do not trust the model to behave. You make the mistake impossible, from the outside. Four barriers: three on my machine, one out of the agent's reach.&lt;/p&gt;

&lt;h3&gt;
  
  
  1. The list of allowed commands
&lt;/h3&gt;

&lt;p&gt;My config file gives the agent a short allowlist. And it refuses the irreversible.&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight json"&gt;&lt;code&gt;&lt;span class="p"&gt;{&lt;/span&gt;&lt;span class="w"&gt;
  &lt;/span&gt;&lt;span class="nl"&gt;"permissions"&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt;&lt;span class="w"&gt; &lt;/span&gt;&lt;span class="p"&gt;{&lt;/span&gt;&lt;span class="w"&gt;
    &lt;/span&gt;&lt;span class="nl"&gt;"allow"&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt;&lt;span class="w"&gt; &lt;/span&gt;&lt;span class="p"&gt;[&lt;/span&gt;&lt;span class="w"&gt;
      &lt;/span&gt;&lt;span class="s2"&gt;"Bash(go build:*)"&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt;&lt;span class="w"&gt; &lt;/span&gt;&lt;span class="s2"&gt;"Bash(go test:*)"&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt;&lt;span class="w"&gt; &lt;/span&gt;&lt;span class="s2"&gt;"Bash(golangci-lint:*)"&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt;&lt;span class="w"&gt;
      &lt;/span&gt;&lt;span class="s2"&gt;"Bash(git commit:*)"&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt;&lt;span class="w"&gt; &lt;/span&gt;&lt;span class="s2"&gt;"Bash(git push:*)"&lt;/span&gt;&lt;span class="w"&gt;
    &lt;/span&gt;&lt;span class="p"&gt;],&lt;/span&gt;&lt;span class="w"&gt;
    &lt;/span&gt;&lt;span class="nl"&gt;"deny"&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt;&lt;span class="w"&gt; &lt;/span&gt;&lt;span class="p"&gt;[&lt;/span&gt;&lt;span class="w"&gt;
      &lt;/span&gt;&lt;span class="s2"&gt;"Bash(git push:*--force*origin main*)"&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt;&lt;span class="w"&gt;
      &lt;/span&gt;&lt;span class="s2"&gt;"Bash(gh pr merge:* main*)"&lt;/span&gt;&lt;span class="w"&gt;
    &lt;/span&gt;&lt;span class="p"&gt;]&lt;/span&gt;&lt;span class="w"&gt;
  &lt;/span&gt;&lt;span class="p"&gt;}&lt;/span&gt;&lt;span class="w"&gt;
&lt;/span&gt;&lt;span class="p"&gt;}&lt;/span&gt;&lt;span class="w"&gt;
&lt;/span&gt;&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;The agent can build, test, commit. It cannot force-push to main. It cannot merge a PR into main. Those two deny lines are worth gold.&lt;/p&gt;

&lt;h3&gt;
  
  
  2. The hook that asks before acting
&lt;/h3&gt;

&lt;p&gt;An allowlist does not cover everything. I add a hook that runs before every command. A hook is a script that runs before an action. Mine spots dangerous commands.&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight javascript"&gt;&lt;code&gt;&lt;span class="c1"&gt;// PreToolUse hook: inspect every Bash command before it runs.&lt;/span&gt;
&lt;span class="kd"&gt;const&lt;/span&gt; &lt;span class="nx"&gt;DANGEROUS&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="p"&gt;[&lt;/span&gt;
  &lt;span class="sr"&gt;/rm&lt;/span&gt;&lt;span class="se"&gt;\s&lt;/span&gt;&lt;span class="sr"&gt;+.*-rf&lt;/span&gt;&lt;span class="se"&gt;\s&lt;/span&gt;&lt;span class="sr"&gt;*&lt;/span&gt;&lt;span class="se"&gt;\/&lt;/span&gt;&lt;span class="sr"&gt;/&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt;                     &lt;span class="c1"&gt;// rm -rf on an absolute path&lt;/span&gt;
  &lt;span class="sr"&gt;/&lt;/span&gt;&lt;span class="se"&gt;\b&lt;/span&gt;&lt;span class="sr"&gt;sudo&lt;/span&gt;&lt;span class="se"&gt;\b&lt;/span&gt;&lt;span class="sr"&gt;/&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="sr"&gt;/&lt;/span&gt;&lt;span class="se"&gt;\b&lt;/span&gt;&lt;span class="sr"&gt;mkfs&lt;/span&gt;&lt;span class="se"&gt;\.&lt;/span&gt;&lt;span class="sr"&gt;/&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="sr"&gt;/&lt;/span&gt;&lt;span class="se"&gt;\b&lt;/span&gt;&lt;span class="sr"&gt;dd&lt;/span&gt;&lt;span class="se"&gt;\s&lt;/span&gt;&lt;span class="sr"&gt;+.*of=&lt;/span&gt;&lt;span class="se"&gt;\/&lt;/span&gt;&lt;span class="sr"&gt;dev&lt;/span&gt;&lt;span class="se"&gt;\/&lt;/span&gt;&lt;span class="sr"&gt;/&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt;
  &lt;span class="sr"&gt;/&lt;/span&gt;&lt;span class="se"&gt;\b(&lt;/span&gt;&lt;span class="sr"&gt;curl|wget&lt;/span&gt;&lt;span class="se"&gt;)\s&lt;/span&gt;&lt;span class="sr"&gt;+.*&lt;/span&gt;&lt;span class="se"&gt;\|\s&lt;/span&gt;&lt;span class="sr"&gt;*&lt;/span&gt;&lt;span class="se"&gt;(&lt;/span&gt;&lt;span class="sr"&gt;sh|bash&lt;/span&gt;&lt;span class="se"&gt;)\b&lt;/span&gt;&lt;span class="sr"&gt;/&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt;  &lt;span class="c1"&gt;// curl | bash&lt;/span&gt;
  &lt;span class="sr"&gt;/&lt;/span&gt;&lt;span class="se"&gt;\/&lt;/span&gt;&lt;span class="sr"&gt;etc&lt;/span&gt;&lt;span class="se"&gt;\/(&lt;/span&gt;&lt;span class="sr"&gt;passwd|shadow&lt;/span&gt;&lt;span class="se"&gt;)&lt;/span&gt;&lt;span class="sr"&gt;/&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt;              &lt;span class="c1"&gt;// reading sensitive files&lt;/span&gt;
&lt;span class="p"&gt;];&lt;/span&gt;

&lt;span class="c1"&gt;// No blind block. Ask a human, and log everything.&lt;/span&gt;
&lt;span class="k"&gt;if &lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="nx"&gt;DANGEROUS&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nf"&gt;some&lt;/span&gt;&lt;span class="p"&gt;((&lt;/span&gt;&lt;span class="nx"&gt;re&lt;/span&gt;&lt;span class="p"&gt;)&lt;/span&gt; &lt;span class="o"&gt;=&amp;gt;&lt;/span&gt; &lt;span class="nx"&gt;re&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nf"&gt;test&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="nx"&gt;cmd&lt;/span&gt;&lt;span class="p"&gt;)))&lt;/span&gt; &lt;span class="p"&gt;{&lt;/span&gt;
  &lt;span class="nx"&gt;console&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nf"&gt;log&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="nx"&gt;JSON&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nf"&gt;stringify&lt;/span&gt;&lt;span class="p"&gt;({&lt;/span&gt; &lt;span class="na"&gt;hookSpecificOutput&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="p"&gt;{&lt;/span&gt;
    &lt;span class="na"&gt;hookEventName&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="dl"&gt;"&lt;/span&gt;&lt;span class="s2"&gt;PreToolUse&lt;/span&gt;&lt;span class="dl"&gt;"&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt;
    &lt;span class="na"&gt;permissionDecision&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="dl"&gt;"&lt;/span&gt;&lt;span class="s2"&gt;ask&lt;/span&gt;&lt;span class="dl"&gt;"&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt;
    &lt;span class="na"&gt;permissionDecisionReason&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="s2"&gt;`Risky command: &lt;/span&gt;&lt;span class="p"&gt;${&lt;/span&gt;&lt;span class="nx"&gt;cmd&lt;/span&gt;&lt;span class="p"&gt;}&lt;/span&gt;&lt;span class="s2"&gt;`&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt;
  &lt;span class="p"&gt;}}));&lt;/span&gt;
&lt;span class="p"&gt;}&lt;/span&gt;
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;It does not block blindly. It asks. A human decides. And every decision goes to a log.&lt;/p&gt;

&lt;h3&gt;
  
  
  3. The pre-commit that guards the repo
&lt;/h3&gt;

&lt;p&gt;A pre-commit is a script that runs before each commit. Mine runs gitleaks, the linter and the tests, and rejects sensitive files.&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight yaml"&gt;&lt;code&gt;&lt;span class="c1"&gt;# lefthook.yml: runs before every commit&lt;/span&gt;
&lt;span class="na"&gt;pre-commit&lt;/span&gt;&lt;span class="pi"&gt;:&lt;/span&gt;
  &lt;span class="na"&gt;commands&lt;/span&gt;&lt;span class="pi"&gt;:&lt;/span&gt;
    &lt;span class="na"&gt;block-secrets&lt;/span&gt;&lt;span class="pi"&gt;:&lt;/span&gt;                 &lt;span class="c1"&gt;# reject .env, .pem, keys&lt;/span&gt;
      &lt;span class="na"&gt;glob&lt;/span&gt;&lt;span class="pi"&gt;:&lt;/span&gt; &lt;span class="pi"&gt;[&lt;/span&gt;&lt;span class="s2"&gt;"&lt;/span&gt;&lt;span class="s"&gt;*.env"&lt;/span&gt;&lt;span class="pi"&gt;,&lt;/span&gt; &lt;span class="s2"&gt;"&lt;/span&gt;&lt;span class="s"&gt;*.pem"&lt;/span&gt;&lt;span class="pi"&gt;,&lt;/span&gt; &lt;span class="s2"&gt;"&lt;/span&gt;&lt;span class="s"&gt;*.key"&lt;/span&gt;&lt;span class="pi"&gt;,&lt;/span&gt; &lt;span class="s2"&gt;"&lt;/span&gt;&lt;span class="s"&gt;id_rsa*"&lt;/span&gt;&lt;span class="pi"&gt;]&lt;/span&gt;
      &lt;span class="na"&gt;run&lt;/span&gt;&lt;span class="pi"&gt;:&lt;/span&gt; &lt;span class="s1"&gt;'&lt;/span&gt;&lt;span class="s"&gt;echo&lt;/span&gt;&lt;span class="nv"&gt; &lt;/span&gt;&lt;span class="s"&gt;"sensitive&lt;/span&gt;&lt;span class="nv"&gt; &lt;/span&gt;&lt;span class="s"&gt;file:&lt;/span&gt;&lt;span class="nv"&gt; &lt;/span&gt;&lt;span class="s"&gt;{staged_files}";&lt;/span&gt;&lt;span class="nv"&gt; &lt;/span&gt;&lt;span class="s"&gt;exit&lt;/span&gt;&lt;span class="nv"&gt; &lt;/span&gt;&lt;span class="s"&gt;1'&lt;/span&gt;
    &lt;span class="na"&gt;gitleaks&lt;/span&gt;&lt;span class="pi"&gt;:&lt;/span&gt;
      &lt;span class="na"&gt;run&lt;/span&gt;&lt;span class="pi"&gt;:&lt;/span&gt; &lt;span class="s"&gt;gitleaks protect --staged --redact&lt;/span&gt;
    &lt;span class="na"&gt;lint&lt;/span&gt;&lt;span class="pi"&gt;:&lt;/span&gt; &lt;span class="pi"&gt;{&lt;/span&gt; &lt;span class="nv"&gt;glob&lt;/span&gt;&lt;span class="pi"&gt;:&lt;/span&gt; &lt;span class="s2"&gt;"&lt;/span&gt;&lt;span class="s"&gt;*.go"&lt;/span&gt;&lt;span class="pi"&gt;,&lt;/span&gt; &lt;span class="nv"&gt;run&lt;/span&gt;&lt;span class="pi"&gt;:&lt;/span&gt; &lt;span class="nv"&gt;golangci-lint run&lt;/span&gt; &lt;span class="pi"&gt;}&lt;/span&gt;
    &lt;span class="na"&gt;test&lt;/span&gt;&lt;span class="pi"&gt;:&lt;/span&gt; &lt;span class="pi"&gt;{&lt;/span&gt; &lt;span class="nv"&gt;glob&lt;/span&gt;&lt;span class="pi"&gt;:&lt;/span&gt; &lt;span class="s2"&gt;"&lt;/span&gt;&lt;span class="s"&gt;*.go"&lt;/span&gt;&lt;span class="pi"&gt;,&lt;/span&gt; &lt;span class="nv"&gt;run&lt;/span&gt;&lt;span class="pi"&gt;:&lt;/span&gt; &lt;span class="nv"&gt;go test ./...&lt;/span&gt; &lt;span class="pi"&gt;}&lt;/span&gt;
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;The key point fits in one sentence. These barriers do not care that an LLM wrote the command. They check the result, not the intent. That is deterministic security.&lt;/p&gt;

&lt;h3&gt;
  
  
  4. The GitHub rule, out of the agent's reach
&lt;/h3&gt;

&lt;p&gt;A local hook can be skipped. A server-side rule cannot. On GitHub, the main branch is protected by a ruleset. A ruleset is a set of rules applied to the branch.&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight yaml"&gt;&lt;code&gt;&lt;span class="c1"&gt;# Protection on the main branch (GitHub ruleset)&lt;/span&gt;
&lt;span class="na"&gt;required_pull_request_reviews&lt;/span&gt;&lt;span class="pi"&gt;:&lt;/span&gt;
  &lt;span class="na"&gt;required_approving_review_count&lt;/span&gt;&lt;span class="pi"&gt;:&lt;/span&gt; &lt;span class="m"&gt;1&lt;/span&gt;   &lt;span class="c1"&gt;# at least one human review&lt;/span&gt;
&lt;span class="na"&gt;required_status_checks&lt;/span&gt;&lt;span class="pi"&gt;:&lt;/span&gt;
  &lt;span class="na"&gt;strict&lt;/span&gt;&lt;span class="pi"&gt;:&lt;/span&gt; &lt;span class="kc"&gt;true&lt;/span&gt;                         &lt;span class="c1"&gt;# CI must be green and up to date&lt;/span&gt;
  &lt;span class="na"&gt;checks&lt;/span&gt;&lt;span class="pi"&gt;:&lt;/span&gt; &lt;span class="pi"&gt;[&lt;/span&gt;&lt;span class="s2"&gt;"&lt;/span&gt;&lt;span class="s"&gt;lint"&lt;/span&gt;&lt;span class="pi"&gt;,&lt;/span&gt; &lt;span class="s2"&gt;"&lt;/span&gt;&lt;span class="s"&gt;test"&lt;/span&gt;&lt;span class="pi"&gt;,&lt;/span&gt; &lt;span class="s2"&gt;"&lt;/span&gt;&lt;span class="s"&gt;gitleaks"&lt;/span&gt;&lt;span class="pi"&gt;]&lt;/span&gt;
&lt;span class="na"&gt;allow_force_pushes&lt;/span&gt;&lt;span class="pi"&gt;:&lt;/span&gt; &lt;span class="kc"&gt;false&lt;/span&gt;              &lt;span class="c1"&gt;# no history rewrite&lt;/span&gt;
&lt;span class="na"&gt;allow_deletions&lt;/span&gt;&lt;span class="pi"&gt;:&lt;/span&gt; &lt;span class="kc"&gt;false&lt;/span&gt;
&lt;span class="c1"&gt;# no bypass: the rule applies to admins too&lt;/span&gt;
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;The nuance matters. The first three barriers live on my machine. This one lives on GitHub. The agent cannot touch it. It is the line nothing crosses.&lt;/p&gt;

&lt;h2&gt;
  
  
  Secrets never pass through the agent
&lt;/h2&gt;

&lt;p&gt;An agent that sees your prod secrets is a leak waiting to happen. So on my stack, it does not see them. Nobody sees them.&lt;/p&gt;

&lt;p&gt;Back to the very start of my infra repo. Second commit. A secret lands in clear text, in the Git history, in plain sight. Two commits. It took two commits for a secret to leak. That is why gitleaks runs on every commit today. gitleaks is a tool that spots secrets in code.&lt;/p&gt;

&lt;p&gt;But gitleaks is only the safety net. The real fix is that no secret is typed by hand anymore. Terraform generates them, then pushes them into a secret manager. A secret manager is a vault that stores and hands out passwords and keys. Mine is Infisical.&lt;/p&gt;

&lt;p&gt;From there, each secret is delivered where it belongs, per environment. Prod gets the prod secrets, staging gets its own. The agent gets none.&lt;/p&gt;

&lt;p&gt;And here is the real point: nobody reads these secrets in clear text. Not the agent. Not me either. I do not know a single production password. You cannot leak what you do not know.&lt;/p&gt;

&lt;h2&gt;
  
  
  In prod, the agent inherits your context, and nothing else
&lt;/h2&gt;

&lt;p&gt;One honest caveat. The local side already runs. The prod side, I am building right now. The cluster is coming up, and I put these rules in place as it does. So what I describe here is the model I am deploying.&lt;/p&gt;

&lt;p&gt;In prod, the agent switches to the prod kubeconfig context. That context is bound to a read-only role. RBAC is the Kubernetes system that says who is allowed to do what.&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight yaml"&gt;&lt;code&gt;&lt;span class="c1"&gt;# The prod context is bound to this role. Read-only, nothing else.&lt;/span&gt;
&lt;span class="na"&gt;apiVersion&lt;/span&gt;&lt;span class="pi"&gt;:&lt;/span&gt; &lt;span class="s"&gt;rbac.authorization.k8s.io/v1&lt;/span&gt;
&lt;span class="na"&gt;kind&lt;/span&gt;&lt;span class="pi"&gt;:&lt;/span&gt; &lt;span class="s"&gt;Role&lt;/span&gt;
&lt;span class="na"&gt;metadata&lt;/span&gt;&lt;span class="pi"&gt;:&lt;/span&gt;
  &lt;span class="na"&gt;namespace&lt;/span&gt;&lt;span class="pi"&gt;:&lt;/span&gt; &lt;span class="s"&gt;prod&lt;/span&gt;
  &lt;span class="na"&gt;name&lt;/span&gt;&lt;span class="pi"&gt;:&lt;/span&gt; &lt;span class="s"&gt;agent-readonly&lt;/span&gt;
&lt;span class="na"&gt;rules&lt;/span&gt;&lt;span class="pi"&gt;:&lt;/span&gt;
  &lt;span class="pi"&gt;-&lt;/span&gt; &lt;span class="na"&gt;apiGroups&lt;/span&gt;&lt;span class="pi"&gt;:&lt;/span&gt; &lt;span class="pi"&gt;[&lt;/span&gt;&lt;span class="s2"&gt;"&lt;/span&gt;&lt;span class="s"&gt;"&lt;/span&gt;&lt;span class="pi"&gt;]&lt;/span&gt;
    &lt;span class="na"&gt;resources&lt;/span&gt;&lt;span class="pi"&gt;:&lt;/span&gt; &lt;span class="pi"&gt;[&lt;/span&gt;&lt;span class="s2"&gt;"&lt;/span&gt;&lt;span class="s"&gt;pods"&lt;/span&gt;&lt;span class="pi"&gt;,&lt;/span&gt; &lt;span class="s2"&gt;"&lt;/span&gt;&lt;span class="s"&gt;pods/log"&lt;/span&gt;&lt;span class="pi"&gt;,&lt;/span&gt; &lt;span class="s2"&gt;"&lt;/span&gt;&lt;span class="s"&gt;events"&lt;/span&gt;&lt;span class="pi"&gt;,&lt;/span&gt; &lt;span class="s2"&gt;"&lt;/span&gt;&lt;span class="s"&gt;services"&lt;/span&gt;&lt;span class="pi"&gt;]&lt;/span&gt;
    &lt;span class="na"&gt;verbs&lt;/span&gt;&lt;span class="pi"&gt;:&lt;/span&gt; &lt;span class="pi"&gt;[&lt;/span&gt;&lt;span class="s2"&gt;"&lt;/span&gt;&lt;span class="s"&gt;get"&lt;/span&gt;&lt;span class="pi"&gt;,&lt;/span&gt; &lt;span class="s2"&gt;"&lt;/span&gt;&lt;span class="s"&gt;list"&lt;/span&gt;&lt;span class="pi"&gt;,&lt;/span&gt; &lt;span class="s2"&gt;"&lt;/span&gt;&lt;span class="s"&gt;watch"&lt;/span&gt;&lt;span class="pi"&gt;]&lt;/span&gt;   &lt;span class="c1"&gt;# no create, update, delete, or exec&lt;/span&gt;
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;No writes. No deletes. No shell inside a pod. The agent can list and observe, and that is all.&lt;/p&gt;

&lt;p&gt;To understand a bug, it reads logs and traces. But it does not poke the cluster. It queries Grafana and Loki, read-only. Loki is the log engine behind Grafana. It touches neither the database nor the secrets.&lt;/p&gt;

&lt;p&gt;This is the Zero Trust principle. You trust no one by default. You verify on every access. An agent is no exception. It is a privileged subject, like a service account.&lt;/p&gt;

&lt;h2&gt;
  
  
  Prod gets fixed by pull request, not by hand
&lt;/h2&gt;

&lt;p&gt;Go back to the agent that tried to delete my secrets. Why did nothing break? Because nothing applies locally. Prod only changes through the pipeline, behind a pull request.&lt;/p&gt;

&lt;p&gt;That is the GitOps idea: you change your infra by editing code, not by hand. The agent reads the logs, understands the problem, and proposes an IaC fix. Review and CI stand in the way. Then the pipeline applies it. Never the agent, never by hand.&lt;/p&gt;

&lt;p&gt;Here is the loop, in practice. A Jaeger trace shows a slow query. The agent spots the missing index. It opens a PR with the migration. I review, CI passes, it ships. It found the bug without ever touching the database.&lt;/p&gt;

&lt;p&gt;Sometimes you need a deeper read in prod. Then I grant read access, temporary and exceptional. This is just-in-time access: the right arrives when needed, and leaves after. You look, then you take it back.&lt;/p&gt;

&lt;h2&gt;
  
  
  Audit everything: an agent is a privileged user
&lt;/h2&gt;

&lt;p&gt;One last security reflex: keep a trace of everything. Zero Trust has a third principle. Assume a breach will happen one day.&lt;/p&gt;

&lt;p&gt;Every agent command goes to a structured log. Every decision from my hooks too. Here is the line left by the agent that tried to delete my secrets.&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight json"&gt;&lt;code&gt;&lt;span class="p"&gt;{&lt;/span&gt;&lt;span class="nl"&gt;"ts"&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt;&lt;span class="s2"&gt;"2026-07-23T14:02:11Z"&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt;&lt;span class="nl"&gt;"tool"&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt;&lt;span class="s2"&gt;"Bash"&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt;&lt;span class="w"&gt;
 &lt;/span&gt;&lt;span class="nl"&gt;"command"&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt;&lt;span class="s2"&gt;"terraform destroy -target=module.secrets"&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt;&lt;span class="w"&gt;
 &lt;/span&gt;&lt;span class="nl"&gt;"decision"&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt;&lt;span class="s2"&gt;"ask"&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt;&lt;span class="nl"&gt;"severity"&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt;&lt;span class="s2"&gt;"HIGH"&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt;&lt;span class="w"&gt;
 &lt;/span&gt;&lt;span class="nl"&gt;"violations"&lt;/span&gt;&lt;span class="p"&gt;:[&lt;/span&gt;&lt;span class="s2"&gt;"destructive terraform on secrets"&lt;/span&gt;&lt;span class="p"&gt;],&lt;/span&gt;&lt;span class="w"&gt;
 &lt;/span&gt;&lt;span class="nl"&gt;"session"&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt;&lt;span class="s2"&gt;"a3f9c1"&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt;&lt;span class="nl"&gt;"source"&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt;&lt;span class="s2"&gt;"claude-code-hook"&lt;/span&gt;&lt;span class="p"&gt;}&lt;/span&gt;&lt;span class="w"&gt;
&lt;/span&gt;&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;An event stream follows what all my agents do, in real time. The day something goes wrong, I answer one question. What did the agent do, exactly? Without that log, you cannot.&lt;/p&gt;

&lt;h2&gt;
  
  
  The per-environment checklist
&lt;/h2&gt;

&lt;p&gt;Before you let a coding agent touch your infra, set its rights against these rules.&lt;/p&gt;

&lt;ul&gt;
&lt;li&gt;Locally, broad access: the agent moves fast, the blast radius is small&lt;/li&gt;
&lt;li&gt;In prod, a read-only kubeconfig context: RBAC get/list/watch, no write, no delete, no exec&lt;/li&gt;
&lt;li&gt;An allowlist of commands, and a deny on the irreversible (force push, merge to main)&lt;/li&gt;
&lt;li&gt;A hook before every command, asking for confirmation on the dangerous ones&lt;/li&gt;
&lt;li&gt;Pre-commit hooks: gitleaks, lint, tests&lt;/li&gt;
&lt;li&gt;On GitHub, main protected server-side: PR required, review required, CI green, force-push blocked&lt;/li&gt;
&lt;li&gt;Secrets generated by IaC, never read by the agent&lt;/li&gt;
&lt;li&gt;Prod logs and traces read read-only (Grafana/Loki), never a shell in the cluster&lt;/li&gt;
&lt;li&gt;Prod changes go through a pull request, applied by the pipeline, never by hand&lt;/li&gt;
&lt;li&gt;Just-in-time prod read access, temporary, then revoked&lt;/li&gt;
&lt;li&gt;Every agent action is logged and auditable&lt;/li&gt;
&lt;/ul&gt;

&lt;h2&gt;
  
  
  The takeaway
&lt;/h2&gt;

&lt;p&gt;An AI agent is not a magic tool. It is a non-human colleague, very fast, with no judgment. Treat it like a dev. Broad locally, locked down in prod.&lt;/p&gt;

&lt;p&gt;And do not count on its good sense. Put the guardrails outside: hooks, pre-commit checks, GitHub rules, RBAC.&lt;/p&gt;

&lt;p&gt;And there is a side effect I did not expect. These barriers do not slow the agent down, they give it room. Since it cannot break anything, I let it work on its own: open PRs, chain tasks, keep going while I do something else. Without them, I would review every command. Boxed in, an agent goes further.&lt;/p&gt;

&lt;p&gt;Remember the agent that tried to delete my secrets. It couldn't. Don't bet on a well-behaved agent. Build an agent that can't break things.&lt;/p&gt;




&lt;p&gt;&lt;em&gt;Originally published at &lt;a href="https://jrobineau.com/blog/2026-07-23-ai-agent-permissions-dev-vs-prod.html" rel="noopener noreferrer"&gt;jrobineau.com&lt;/a&gt;. I'm Jules Robineau, a senior Go backend &amp;amp; DevSecOps freelancer based in Paris. I build and harden production AI/backend systems at scale. &lt;a href="https://jrobineau.com/services.html" rel="noopener noreferrer"&gt;Get in touch&lt;/a&gt;.&lt;/em&gt;&lt;/p&gt;

</description>
      <category>ai</category>
      <category>security</category>
      <category>devops</category>
      <category>llm</category>
    </item>
    <item>
      <title>Rebuild It to Understand It: From Network Protocols to LLM Agents</title>
      <dc:creator>Jules Robineau</dc:creator>
      <pubDate>Fri, 24 Jul 2026 07:30:00 +0000</pubDate>
      <link>https://dev.to/julesrobineau/rebuild-it-to-understand-it-from-network-protocols-to-llm-agents-35fa</link>
      <guid>https://dev.to/julesrobineau/rebuild-it-to-understand-it-from-network-protocols-to-llm-agents-35fa</guid>
      <description>&lt;blockquote&gt;
&lt;p&gt;&lt;strong&gt;TL;DR&lt;/strong&gt;: you only truly understand a system once you rebuild it. I recoded TCP at school, then the DNS protocol, then Modbus, each time to understand it from the inside. A colleague just went through this with LLMs. He wrote a small agent in Go, and he finally understood tooling and the context window. An LLM is just one more system to demystify. Rebuild a tiny version, and you move from user to engineer.&lt;/p&gt;
&lt;/blockquote&gt;

&lt;p&gt;For developers who want to master LLMs, not just use them.&lt;/p&gt;

&lt;h2&gt;
  
  
  A colleague, a Go agent, a click
&lt;/h2&gt;

&lt;p&gt;This week, I am helping a colleague level up on LLMs. I explain the concepts. Context, tokens, tools. A token is a small piece of text the model reads and counts. He listens, but something does not click.&lt;/p&gt;

&lt;p&gt;Then he comes back, delighted. He wrote a small CLI in Go. A plain chat loop that calls a model and runs its tools. And now he gets it. The tooling, the context window, the loop. Not because I explained it. Because he rebuilt it.&lt;/p&gt;

&lt;p&gt;I know that click by heart. I have felt it many times, on other topics. Always the same method. To understand something, I rebuild it.&lt;/p&gt;

&lt;h2&gt;
  
  
  You only understand a system by rebuilding it
&lt;/h2&gt;

&lt;p&gt;Reading the docs gives you a map. Rebuilding gives you the terrain. They are not the same. The map says "there is a river here". The terrain lets you feel the current.&lt;/p&gt;

&lt;p&gt;When you rewrite a system, you can no longer bluff. Every byte has to sit in the right place. Every edge case lands on you. You do not think you understand. You understand, or your code fails.&lt;/p&gt;

&lt;p&gt;This is not an academic exercise. It is the opposite. You rebuild to act better afterward. To debug faster. To bend the tool. To build what no off-the-shelf library gives you.&lt;/p&gt;

&lt;h2&gt;
  
  
  TCP in C: the first time
&lt;/h2&gt;

&lt;p&gt;The very first time was at school, in C. I recoded pieces of TCP and UDP. The famous three-step handshake. And header parsing, field by field.&lt;/p&gt;

&lt;p&gt;TCP opens a connection in three messages. SYN, SYN-ACK, ACK. Before, that was one line in a lecture. After, it was bytes I placed into a packet myself.&lt;/p&gt;

&lt;p&gt;I invented nothing. The protocol had existed for forty years. But redoing it changed how I see the network. Since then, a packet capture is no mystery. It is a format I have written by hand.&lt;/p&gt;

&lt;h2&gt;
  
  
  DNS: recode the protocol to bend its subdomains
&lt;/h2&gt;

&lt;p&gt;Later, I took on DNS. DNS turns a name like jrobineau.com into an IP address. I rewrote it myself, in Go. The header, the questions, the answers, byte by byte.&lt;/p&gt;

&lt;p&gt;Rebuilding it, you hit a detail the docs gloss over. A domain name is a series of labels. Each label is prefixed by its length. "www" is a 3, then w, w, w.&lt;/p&gt;

&lt;p&gt;And then an idea shows up. If I control the labels, I control bytes. I can slip my own data into a subdomain. That is the principle of DNS exfiltration, in an authorized security context.&lt;/p&gt;

&lt;p&gt;My server receives the query, parses the name, and recovers the data hidden inside. I even handled name compression, a nasty corner of the protocol. No ready-made library would have shown me that. Rebuilding it did.&lt;/p&gt;

&lt;h2&gt;
  
  
  Modbus: rebuild it to find who owns the bug
&lt;/h2&gt;

&lt;p&gt;On a job with industrial hardware, we spoke Modbus. Modbus is an old protocol that drives controllers and sensors. The library we used was bad. Many bugs, much strange behavior.&lt;/p&gt;

&lt;p&gt;There was no way to tell where the pain came from. The protocol? The library? Our code? So I did the one thing that settles it. I recoded Modbus myself, in Go.&lt;/p&gt;

&lt;p&gt;The verdict: the protocol was fine. The culprit was the library. And once the protocol was rebuilt, the real payoff arrived. I could build tools around it, my way.&lt;/p&gt;

&lt;p&gt;I turned it into a small Go library with a Gin-like API. You declare a handler per register range. You add logging and recovery middleware. A 1979 industrial protocol, with the comfort of a web framework. That is bending knowledge to your need.&lt;/p&gt;

&lt;h2&gt;
  
  
  An LLM is one more system to rebuild
&lt;/h2&gt;

&lt;p&gt;Back to LLMs. In 2026, AI is sold as magic. A black box you talk to. And you stay a user, a bit passive, a bit at its mercy.&lt;/p&gt;

&lt;p&gt;But an LLM agent is not magic. It is a loop. You send messages to the model. It replies, sometimes asking for a tool. You run the tool. You send the result back. And you start again.&lt;/p&gt;

&lt;p&gt;The context window is everything the model sees right now. Your loop decides what goes in, and what to drop. The model remembers nothing. You are the one who feeds it the past on every turn.&lt;/p&gt;

&lt;p&gt;Here is the whole loop, in Go. Strip the varnish, and only this is left.&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight go"&gt;&lt;code&gt;&lt;span class="k"&gt;func&lt;/span&gt; &lt;span class="n"&gt;runAgent&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="n"&gt;ctx&lt;/span&gt; &lt;span class="n"&gt;context&lt;/span&gt;&lt;span class="o"&gt;.&lt;/span&gt;&lt;span class="n"&gt;Context&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="n"&gt;client&lt;/span&gt; &lt;span class="n"&gt;LLM&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="n"&gt;tools&lt;/span&gt; &lt;span class="k"&gt;map&lt;/span&gt;&lt;span class="p"&gt;[&lt;/span&gt;&lt;span class="kt"&gt;string&lt;/span&gt;&lt;span class="p"&gt;]&lt;/span&gt;&lt;span class="n"&gt;Tool&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="n"&gt;goal&lt;/span&gt; &lt;span class="kt"&gt;string&lt;/span&gt;&lt;span class="p"&gt;)&lt;/span&gt; &lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="kt"&gt;string&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="kt"&gt;error&lt;/span&gt;&lt;span class="p"&gt;)&lt;/span&gt; &lt;span class="p"&gt;{&lt;/span&gt;
    &lt;span class="c"&gt;// The context window is this list. You alone fill it.&lt;/span&gt;
    &lt;span class="n"&gt;msgs&lt;/span&gt; &lt;span class="o"&gt;:=&lt;/span&gt; &lt;span class="p"&gt;[]&lt;/span&gt;&lt;span class="n"&gt;Message&lt;/span&gt;&lt;span class="p"&gt;{{&lt;/span&gt;&lt;span class="n"&gt;Role&lt;/span&gt;&lt;span class="o"&gt;:&lt;/span&gt; &lt;span class="s"&gt;"user"&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="n"&gt;Content&lt;/span&gt;&lt;span class="o"&gt;:&lt;/span&gt; &lt;span class="n"&gt;goal&lt;/span&gt;&lt;span class="p"&gt;}}&lt;/span&gt;

    &lt;span class="k"&gt;for&lt;/span&gt; &lt;span class="p"&gt;{&lt;/span&gt;
        &lt;span class="c"&gt;// 1. You send the whole context to the model.&lt;/span&gt;
        &lt;span class="n"&gt;reply&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="n"&gt;err&lt;/span&gt; &lt;span class="o"&gt;:=&lt;/span&gt; &lt;span class="n"&gt;client&lt;/span&gt;&lt;span class="o"&gt;.&lt;/span&gt;&lt;span class="n"&gt;Complete&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="n"&gt;ctx&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="n"&gt;msgs&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="n"&gt;tools&lt;/span&gt;&lt;span class="p"&gt;)&lt;/span&gt;
        &lt;span class="k"&gt;if&lt;/span&gt; &lt;span class="n"&gt;err&lt;/span&gt; &lt;span class="o"&gt;!=&lt;/span&gt; &lt;span class="no"&gt;nil&lt;/span&gt; &lt;span class="p"&gt;{&lt;/span&gt;
            &lt;span class="k"&gt;return&lt;/span&gt; &lt;span class="s"&gt;""&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="n"&gt;err&lt;/span&gt;
        &lt;span class="p"&gt;}&lt;/span&gt;
        &lt;span class="n"&gt;msgs&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="nb"&gt;append&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="n"&gt;msgs&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="n"&gt;reply&lt;/span&gt;&lt;span class="p"&gt;)&lt;/span&gt;

        &lt;span class="c"&gt;// 2. No tool requested? The model is done, you return.&lt;/span&gt;
        &lt;span class="k"&gt;if&lt;/span&gt; &lt;span class="nb"&gt;len&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="n"&gt;reply&lt;/span&gt;&lt;span class="o"&gt;.&lt;/span&gt;&lt;span class="n"&gt;ToolCalls&lt;/span&gt;&lt;span class="p"&gt;)&lt;/span&gt; &lt;span class="o"&gt;==&lt;/span&gt; &lt;span class="m"&gt;0&lt;/span&gt; &lt;span class="p"&gt;{&lt;/span&gt;
            &lt;span class="k"&gt;return&lt;/span&gt; &lt;span class="n"&gt;reply&lt;/span&gt;&lt;span class="o"&gt;.&lt;/span&gt;&lt;span class="n"&gt;Content&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="no"&gt;nil&lt;/span&gt;
        &lt;span class="p"&gt;}&lt;/span&gt;

        &lt;span class="c"&gt;// 3. You run each tool yourself, not the model.&lt;/span&gt;
        &lt;span class="k"&gt;for&lt;/span&gt; &lt;span class="n"&gt;_&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="n"&gt;call&lt;/span&gt; &lt;span class="o"&gt;:=&lt;/span&gt; &lt;span class="k"&gt;range&lt;/span&gt; &lt;span class="n"&gt;reply&lt;/span&gt;&lt;span class="o"&gt;.&lt;/span&gt;&lt;span class="n"&gt;ToolCalls&lt;/span&gt; &lt;span class="p"&gt;{&lt;/span&gt;
            &lt;span class="n"&gt;out&lt;/span&gt; &lt;span class="o"&gt;:=&lt;/span&gt; &lt;span class="n"&gt;tools&lt;/span&gt;&lt;span class="p"&gt;[&lt;/span&gt;&lt;span class="n"&gt;call&lt;/span&gt;&lt;span class="o"&gt;.&lt;/span&gt;&lt;span class="n"&gt;Name&lt;/span&gt;&lt;span class="p"&gt;]&lt;/span&gt;&lt;span class="o"&gt;.&lt;/span&gt;&lt;span class="n"&gt;Run&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="n"&gt;ctx&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="n"&gt;call&lt;/span&gt;&lt;span class="o"&gt;.&lt;/span&gt;&lt;span class="n"&gt;Args&lt;/span&gt;&lt;span class="p"&gt;)&lt;/span&gt;
            &lt;span class="c"&gt;// 4. You feed the result back into the context. Loop again.&lt;/span&gt;
            &lt;span class="n"&gt;msgs&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="nb"&gt;append&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="n"&gt;msgs&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="n"&gt;Message&lt;/span&gt;&lt;span class="p"&gt;{&lt;/span&gt;&lt;span class="n"&gt;Role&lt;/span&gt;&lt;span class="o"&gt;:&lt;/span&gt; &lt;span class="s"&gt;"tool"&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="n"&gt;Content&lt;/span&gt;&lt;span class="o"&gt;:&lt;/span&gt; &lt;span class="n"&gt;out&lt;/span&gt;&lt;span class="p"&gt;})&lt;/span&gt;
        &lt;span class="p"&gt;}&lt;/span&gt;
    &lt;span class="p"&gt;}&lt;/span&gt;
&lt;span class="p"&gt;}&lt;/span&gt;
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;Once you have written this, the fear fades. An "agent" is this loop plus a few good tools. Tool calling is just the model telling you which function to call. Nothing more.&lt;/p&gt;

&lt;h2&gt;
  
  
  Rebuild, yes, but not everything, not forever
&lt;/h2&gt;

&lt;p&gt;The goal is not to rewrite everything for life. I do not ship my own TCP stack to production. I use the system's, and I am right to.&lt;/p&gt;

&lt;p&gt;You rebuild once, to understand. Then you trust, because you know what is in the box. It is earned trust, not blind trust.&lt;/p&gt;

&lt;p&gt;Rebuild when the stakes are high. A protocol at the core of your product. A tool you will debug often. A new tech, like LLMs, where everyone stays on the surface. That is where understanding pays.&lt;/p&gt;

&lt;h2&gt;
  
  
  The checklist for learning a tech by rebuilding it
&lt;/h2&gt;

&lt;blockquote&gt;
&lt;p&gt;The next tech that impresses you, do not just use it. Rebuild a piece of it.&lt;/p&gt;
&lt;/blockquote&gt;

&lt;ul&gt;
&lt;li&gt;[ ] Aim at the core, not the comfort. The agent loop, not the whole vendor API&lt;/li&gt;
&lt;li&gt;[ ] Keep it small. A CLI, one file, an afternoon are often enough&lt;/li&gt;
&lt;li&gt;[ ] Write the format by hand once. The bytes teach what the docs hide&lt;/li&gt;
&lt;li&gt;[ ] Hunt for the unlocking detail. DNS labels, the context loop&lt;/li&gt;
&lt;li&gt;[ ] Break it on purpose. You quickly see the limits and the traps&lt;/li&gt;
&lt;li&gt;[ ] A library lets you down? Rebuild to learn who really owns the bug&lt;/li&gt;
&lt;li&gt;[ ] Once you get it, bend it. Build the tool no ready-made library gives you&lt;/li&gt;
&lt;li&gt;[ ] Then drop your toy version. Go back to the production library, with a real mental model&lt;/li&gt;
&lt;/ul&gt;

&lt;h2&gt;
  
  
  What to remember
&lt;/h2&gt;

&lt;p&gt;The dev mojo has not changed. Using a tool without understanding it means staying at its mercy. Rebuilding it, even as a toy, takes back control.&lt;/p&gt;

&lt;p&gt;TCP, DNS, Modbus, an LLM agent. Every time, the same method. Rebuild to understand. Understand to bend. LLMs are simply the next system on the list.&lt;/p&gt;

&lt;p&gt;Training a team on LLMs, or want Go backend that holds up? &lt;a href="https://jrobineau.com/contact.html" rel="noopener noreferrer"&gt;That is what I do. Write to me.&lt;/a&gt; We do not suffer our tools. We understand them.&lt;/p&gt;




&lt;p&gt;&lt;strong&gt;Sources:&lt;/strong&gt; &lt;a href="https://www.rfc-editor.org/rfc/rfc1035" rel="noopener noreferrer"&gt;RFC 1035, DNS format&lt;/a&gt; · &lt;a href="https://www.rfc-editor.org/rfc/rfc9293" rel="noopener noreferrer"&gt;RFC 9293, TCP&lt;/a&gt; · &lt;a href="https://modbus.org/docs/Modbus_Application_Protocol_V1_1b3.pdf" rel="noopener noreferrer"&gt;Modbus Application Protocol&lt;/a&gt; · &lt;a href="https://www.anthropic.com/research/building-effective-agents" rel="noopener noreferrer"&gt;Anthropic, Building effective agents&lt;/a&gt;&lt;/p&gt;

</description>
      <category>go</category>
      <category>llm</category>
      <category>ai</category>
      <category>career</category>
    </item>
    <item>
      <title>Tenant Isolation with Postgres Row-Level Security in Go</title>
      <dc:creator>Jules Robineau</dc:creator>
      <pubDate>Fri, 24 Jul 2026 07:00:00 +0000</pubDate>
      <link>https://dev.to/julesrobineau/tenant-isolation-with-postgres-row-level-security-in-go-1lp8</link>
      <guid>https://dev.to/julesrobineau/tenant-isolation-with-postgres-row-level-security-in-go-1lp8</guid>
      <description>&lt;blockquote&gt;
&lt;p&gt;Multi-tenant security should not depend on your Go code. One missing &lt;code&gt;if&lt;/code&gt;, and a client sees another client's data. Postgres can hold the wall for you, with Row-Level Security. You enable one policy per table, set the tenant on every connection, and the database filters on its own. Here is how I wired it in Go with &lt;code&gt;pgx&lt;/code&gt;, the two traps that cost me time, and how I prove it with tests.&lt;/p&gt;
&lt;/blockquote&gt;

&lt;p&gt;&lt;strong&gt;Who this is for:&lt;/strong&gt; Go developers building a multi-tenant SaaS on Postgres who want isolation that holds, even when the code has a bug.&lt;/p&gt;

&lt;h2&gt;
  
  
  The setup
&lt;/h2&gt;

&lt;p&gt;A tenant is a customer of your application. In a multi-tenant SaaS, many customers share one database. Their data lives in the same tables, split by a &lt;code&gt;tenant_id&lt;/code&gt; column.&lt;/p&gt;

&lt;p&gt;The risk is simple. If one query forgets its &lt;code&gt;WHERE tenant_id = ...&lt;/code&gt; filter, a customer reads another customer's rows. In healthcare, finance, or with personal data, that is a serious leak.&lt;/p&gt;

&lt;p&gt;I built this pattern on my own product, a rental-management tool with an AI legal advisor. I touched on RLS in my &lt;a href="https://jrobineau.com/blog/mcp-secure-go.html" rel="noopener noreferrer"&gt;article on securing LLM agents&lt;/a&gt;. This is the full version.&lt;/p&gt;

&lt;h2&gt;
  
  
  RLS in one minute
&lt;/h2&gt;

&lt;p&gt;Row-Level Security, or RLS, is a Postgres feature. It decides, row by row, what a query may read and write. You turn it on per table, with a policy.&lt;/p&gt;

&lt;p&gt;A policy has two parts. &lt;code&gt;USING&lt;/code&gt; filters the rows you read. &lt;code&gt;WITH CHECK&lt;/code&gt; blocks writes that break the rule. Here is the &lt;code&gt;conversations&lt;/code&gt; table with tenant isolation:&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight sql"&gt;&lt;code&gt;&lt;span class="k"&gt;ALTER&lt;/span&gt; &lt;span class="k"&gt;TABLE&lt;/span&gt; &lt;span class="n"&gt;conversations&lt;/span&gt; &lt;span class="n"&gt;ENABLE&lt;/span&gt; &lt;span class="k"&gt;ROW&lt;/span&gt; &lt;span class="k"&gt;LEVEL&lt;/span&gt; &lt;span class="k"&gt;SECURITY&lt;/span&gt;&lt;span class="p"&gt;;&lt;/span&gt;
&lt;span class="k"&gt;ALTER&lt;/span&gt; &lt;span class="k"&gt;TABLE&lt;/span&gt; &lt;span class="n"&gt;conversations&lt;/span&gt; &lt;span class="k"&gt;FORCE&lt;/span&gt;  &lt;span class="k"&gt;ROW&lt;/span&gt; &lt;span class="k"&gt;LEVEL&lt;/span&gt; &lt;span class="k"&gt;SECURITY&lt;/span&gt;&lt;span class="p"&gt;;&lt;/span&gt;

&lt;span class="k"&gt;CREATE&lt;/span&gt; &lt;span class="n"&gt;POLICY&lt;/span&gt; &lt;span class="n"&gt;tenant_isolation&lt;/span&gt; &lt;span class="k"&gt;ON&lt;/span&gt; &lt;span class="n"&gt;conversations&lt;/span&gt;
    &lt;span class="k"&gt;FOR&lt;/span&gt; &lt;span class="k"&gt;ALL&lt;/span&gt;
    &lt;span class="k"&gt;USING&lt;/span&gt;      &lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="n"&gt;tenant_id&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="n"&gt;current_setting&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="s1"&gt;'app.current_tenant_id'&lt;/span&gt;&lt;span class="p"&gt;)::&lt;/span&gt;&lt;span class="n"&gt;uuid&lt;/span&gt;&lt;span class="p"&gt;)&lt;/span&gt;
    &lt;span class="k"&gt;WITH&lt;/span&gt; &lt;span class="k"&gt;CHECK&lt;/span&gt; &lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="n"&gt;tenant_id&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="n"&gt;current_setting&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="s1"&gt;'app.current_tenant_id'&lt;/span&gt;&lt;span class="p"&gt;)::&lt;/span&gt;&lt;span class="n"&gt;uuid&lt;/span&gt;&lt;span class="p"&gt;);&lt;/span&gt;
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;&lt;code&gt;current_setting('app.current_tenant_id')&lt;/code&gt; reads a session variable. Your code fills it with the user's tenant. Then Postgres compares every row to that value. A query sees only its rows. The others do not exist for it.&lt;/p&gt;

&lt;p&gt;&lt;code&gt;FORCE ROW LEVEL SECURITY&lt;/code&gt; adds one guarantee. Without it, the table owner skips RLS. With it, even the owner obeys the policy.&lt;/p&gt;

&lt;p&gt;None of this SQL is specific to Go. The policies and the session variable are the same whether your backend is Python, Node, Rails, or Java. What changes from one language to the next is how you set the tenant on each request. And that is where connection pooling gets tricky. I show that part in Go, with &lt;code&gt;pgx&lt;/code&gt;.&lt;/p&gt;

&lt;h2&gt;
  
  
  Two Postgres roles, not one
&lt;/h2&gt;

&lt;p&gt;RLS is useless if your application can turn it off. So I create two Postgres roles. A role is a database user.&lt;/p&gt;

&lt;p&gt;The first, &lt;code&gt;app_role&lt;/code&gt;, runs every user query. It is subject to RLS.&lt;/p&gt;

&lt;p&gt;The second, &lt;code&gt;system_role&lt;/code&gt;, has the &lt;code&gt;BYPASSRLS&lt;/code&gt; attribute. It ignores RLS. I keep it for migrations, admin tasks, and the rare queries that must see several tenants.&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight sql"&gt;&lt;code&gt;&lt;span class="k"&gt;CREATE&lt;/span&gt; &lt;span class="k"&gt;ROLE&lt;/span&gt; &lt;span class="n"&gt;app_role&lt;/span&gt;    &lt;span class="k"&gt;WITH&lt;/span&gt; &lt;span class="n"&gt;LOGIN&lt;/span&gt; &lt;span class="n"&gt;PASSWORD&lt;/span&gt; &lt;span class="s1"&gt;'...'&lt;/span&gt;&lt;span class="p"&gt;;&lt;/span&gt;
&lt;span class="k"&gt;CREATE&lt;/span&gt; &lt;span class="k"&gt;ROLE&lt;/span&gt; &lt;span class="n"&gt;system_role&lt;/span&gt; &lt;span class="k"&gt;WITH&lt;/span&gt; &lt;span class="n"&gt;LOGIN&lt;/span&gt; &lt;span class="n"&gt;PASSWORD&lt;/span&gt; &lt;span class="s1"&gt;'...'&lt;/span&gt; &lt;span class="n"&gt;BYPASSRLS&lt;/span&gt;&lt;span class="p"&gt;;&lt;/span&gt;
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;The rule is simple. Code that answers users always goes through &lt;code&gt;app_role&lt;/code&gt;. &lt;code&gt;system_role&lt;/code&gt; stays for internal work, never for a user-triggered query. Two separate connection pools, one per role.&lt;/p&gt;

&lt;h2&gt;
  
  
  Set the tenant on every connection
&lt;/h2&gt;

&lt;p&gt;The &lt;code&gt;app.current_tenant_id&lt;/code&gt; variable must be set before every user query. Setting it by hand in each function repeats the missing-&lt;code&gt;if&lt;/code&gt; problem.&lt;/p&gt;

&lt;p&gt;So I do it once, in the connection pool. &lt;code&gt;pgx&lt;/code&gt; is the Postgres driver for Go. Its pool can run a function on each connection before it hands it to you.&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight go"&gt;&lt;code&gt;&lt;span class="n"&gt;poolCfg&lt;/span&gt;&lt;span class="o"&gt;.&lt;/span&gt;&lt;span class="n"&gt;PrepareConn&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="k"&gt;func&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="n"&gt;ctx&lt;/span&gt; &lt;span class="n"&gt;context&lt;/span&gt;&lt;span class="o"&gt;.&lt;/span&gt;&lt;span class="n"&gt;Context&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="n"&gt;conn&lt;/span&gt; &lt;span class="o"&gt;*&lt;/span&gt;&lt;span class="n"&gt;pgx&lt;/span&gt;&lt;span class="o"&gt;.&lt;/span&gt;&lt;span class="n"&gt;Conn&lt;/span&gt;&lt;span class="p"&gt;)&lt;/span&gt; &lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="kt"&gt;bool&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="kt"&gt;error&lt;/span&gt;&lt;span class="p"&gt;)&lt;/span&gt; &lt;span class="p"&gt;{&lt;/span&gt;
    &lt;span class="n"&gt;tenantID&lt;/span&gt; &lt;span class="o"&gt;:=&lt;/span&gt; &lt;span class="n"&gt;middleware&lt;/span&gt;&lt;span class="o"&gt;.&lt;/span&gt;&lt;span class="n"&gt;GetTenantID&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="n"&gt;ctx&lt;/span&gt;&lt;span class="p"&gt;)&lt;/span&gt;
    &lt;span class="k"&gt;if&lt;/span&gt; &lt;span class="n"&gt;tenantID&lt;/span&gt; &lt;span class="o"&gt;==&lt;/span&gt; &lt;span class="s"&gt;""&lt;/span&gt; &lt;span class="p"&gt;{&lt;/span&gt;
        &lt;span class="k"&gt;return&lt;/span&gt; &lt;span class="no"&gt;true&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="no"&gt;nil&lt;/span&gt;
    &lt;span class="p"&gt;}&lt;/span&gt;
    &lt;span class="n"&gt;_&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="n"&gt;err&lt;/span&gt; &lt;span class="o"&gt;:=&lt;/span&gt; &lt;span class="n"&gt;conn&lt;/span&gt;&lt;span class="o"&gt;.&lt;/span&gt;&lt;span class="n"&gt;Exec&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="n"&gt;ctx&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt;
        &lt;span class="s"&gt;"SELECT set_config('app.current_tenant_id', $1, false)"&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="n"&gt;tenantID&lt;/span&gt;&lt;span class="p"&gt;)&lt;/span&gt;
    &lt;span class="k"&gt;if&lt;/span&gt; &lt;span class="n"&gt;err&lt;/span&gt; &lt;span class="o"&gt;!=&lt;/span&gt; &lt;span class="no"&gt;nil&lt;/span&gt; &lt;span class="p"&gt;{&lt;/span&gt;
        &lt;span class="k"&gt;return&lt;/span&gt; &lt;span class="no"&gt;false&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="n"&gt;err&lt;/span&gt;
    &lt;span class="p"&gt;}&lt;/span&gt;
    &lt;span class="k"&gt;return&lt;/span&gt; &lt;span class="no"&gt;true&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="no"&gt;nil&lt;/span&gt;
&lt;span class="p"&gt;}&lt;/span&gt;

&lt;span class="n"&gt;poolCfg&lt;/span&gt;&lt;span class="o"&gt;.&lt;/span&gt;&lt;span class="n"&gt;AfterRelease&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="k"&gt;func&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="n"&gt;conn&lt;/span&gt; &lt;span class="o"&gt;*&lt;/span&gt;&lt;span class="n"&gt;pgx&lt;/span&gt;&lt;span class="o"&gt;.&lt;/span&gt;&lt;span class="n"&gt;Conn&lt;/span&gt;&lt;span class="p"&gt;)&lt;/span&gt; &lt;span class="kt"&gt;bool&lt;/span&gt; &lt;span class="p"&gt;{&lt;/span&gt;
    &lt;span class="n"&gt;_&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="n"&gt;_&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="n"&gt;conn&lt;/span&gt;&lt;span class="o"&gt;.&lt;/span&gt;&lt;span class="n"&gt;Exec&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="n"&gt;context&lt;/span&gt;&lt;span class="o"&gt;.&lt;/span&gt;&lt;span class="n"&gt;Background&lt;/span&gt;&lt;span class="p"&gt;(),&lt;/span&gt; &lt;span class="s"&gt;"RESET app.current_tenant_id"&lt;/span&gt;&lt;span class="p"&gt;)&lt;/span&gt;
    &lt;span class="k"&gt;return&lt;/span&gt; &lt;span class="no"&gt;true&lt;/span&gt;
&lt;span class="p"&gt;}&lt;/span&gt;
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;&lt;code&gt;PrepareConn&lt;/code&gt; runs when the pool takes a connection out. It reads the tenant from the request context, then fills the variable. In older &lt;code&gt;pgx&lt;/code&gt; versions, the hook is named &lt;code&gt;BeforeAcquire&lt;/code&gt;.&lt;/p&gt;

&lt;p&gt;&lt;code&gt;AfterRelease&lt;/code&gt; runs when the connection comes back. It calls &lt;code&gt;RESET&lt;/code&gt;. This detail is vital. A connection is reused by other requests. Without &lt;code&gt;RESET&lt;/code&gt;, it keeps the last user's tenant. The next request would inherit it. That would be a leak.&lt;/p&gt;

&lt;p&gt;The tenant comes from the context. I put it there from the user's JWT. A JWT is a signed token that proves who the user is. A middleware checks the token, reads the tenant, and stores it in the context.&lt;/p&gt;

&lt;h2&gt;
  
  
  First trap: RLS fails silently
&lt;/h2&gt;

&lt;p&gt;RLS raises no error when it blocks. It makes rows invisible. That is the intended behavior, but it surprises people.&lt;/p&gt;

&lt;p&gt;An example. Tenant B tries to update a tenant A row. RLS does not refuse. The &lt;code&gt;UPDATE&lt;/code&gt; touches zero rows. No error. Your Go code thinks the update worked.&lt;/p&gt;

&lt;p&gt;Same for a cross-tenant &lt;code&gt;DELETE&lt;/code&gt;: zero rows, no error. And reading another tenant's row returns "nothing found", not "forbidden".&lt;/p&gt;

&lt;p&gt;Two consequences for your code. One, never read a missing error as success on a write. Check the affected-row count when an operation must change something. Two, treat "zero rows" as a clean "not found", not as a bug.&lt;/p&gt;

&lt;h2&gt;
  
  
  Second trap: forgetting the tenant
&lt;/h2&gt;

&lt;p&gt;What happens if the variable is never set? The policy cannot evaluate. The query fails.&lt;/p&gt;

&lt;p&gt;That is a good default. No tenant, no data. RLS fails closed, never open. A missing variable blocks everything instead of showing everything.&lt;/p&gt;

&lt;p&gt;I double this guarantee in the application. A middleware rejects any request without a tenant, with a 403. The database is the last wall. The middleware is the first.&lt;/p&gt;

&lt;h2&gt;
  
  
  Infinite recursion, error 42P17
&lt;/h2&gt;

&lt;p&gt;Here is the bug that cost me the most time. It happens when two tables protect each other.&lt;/p&gt;

&lt;p&gt;My &lt;code&gt;conversations&lt;/code&gt; table had a policy that looked at &lt;code&gt;conversation_participants&lt;/code&gt;. And &lt;code&gt;conversation_participants&lt;/code&gt; had a policy that looked at &lt;code&gt;conversations&lt;/code&gt;. Each policy triggered the other. Postgres looped, then returned &lt;code&gt;SQLSTATE 42P17&lt;/code&gt;: infinite recursion in a policy.&lt;/p&gt;

&lt;p&gt;The fix is two words: &lt;code&gt;SECURITY DEFINER&lt;/code&gt;. I move the access logic into a function. The function checks access once, and runs with its creator's rights. So it ignores RLS inside. No more loop.&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight sql"&gt;&lt;code&gt;&lt;span class="k"&gt;CREATE&lt;/span&gt; &lt;span class="k"&gt;FUNCTION&lt;/span&gt; &lt;span class="n"&gt;user_can_access_conversation&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="n"&gt;conv_id&lt;/span&gt; &lt;span class="n"&gt;uuid&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="n"&gt;uid&lt;/span&gt; &lt;span class="n"&gt;uuid&lt;/span&gt;&lt;span class="p"&gt;)&lt;/span&gt;
&lt;span class="k"&gt;RETURNS&lt;/span&gt; &lt;span class="nb"&gt;boolean&lt;/span&gt; &lt;span class="k"&gt;AS&lt;/span&gt; &lt;span class="err"&gt;$$&lt;/span&gt;
    &lt;span class="k"&gt;SELECT&lt;/span&gt; &lt;span class="k"&gt;EXISTS&lt;/span&gt; &lt;span class="p"&gt;(&lt;/span&gt;
        &lt;span class="k"&gt;SELECT&lt;/span&gt; &lt;span class="mi"&gt;1&lt;/span&gt; &lt;span class="k"&gt;FROM&lt;/span&gt; &lt;span class="n"&gt;conversations&lt;/span&gt;
        &lt;span class="k"&gt;WHERE&lt;/span&gt; &lt;span class="n"&gt;id&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="n"&gt;conv_id&lt;/span&gt; &lt;span class="k"&gt;AND&lt;/span&gt; &lt;span class="n"&gt;owner_id&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="n"&gt;uid&lt;/span&gt;
    &lt;span class="p"&gt;)&lt;/span&gt; &lt;span class="k"&gt;OR&lt;/span&gt; &lt;span class="k"&gt;EXISTS&lt;/span&gt; &lt;span class="p"&gt;(&lt;/span&gt;
        &lt;span class="k"&gt;SELECT&lt;/span&gt; &lt;span class="mi"&gt;1&lt;/span&gt; &lt;span class="k"&gt;FROM&lt;/span&gt; &lt;span class="n"&gt;conversation_participants&lt;/span&gt;
        &lt;span class="k"&gt;WHERE&lt;/span&gt; &lt;span class="n"&gt;conversation_id&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="n"&gt;conv_id&lt;/span&gt; &lt;span class="k"&gt;AND&lt;/span&gt; &lt;span class="n"&gt;user_id&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="n"&gt;uid&lt;/span&gt;
    &lt;span class="p"&gt;);&lt;/span&gt;
&lt;span class="err"&gt;$$&lt;/span&gt; &lt;span class="k"&gt;LANGUAGE&lt;/span&gt; &lt;span class="k"&gt;sql&lt;/span&gt; &lt;span class="k"&gt;SECURITY&lt;/span&gt; &lt;span class="k"&gt;DEFINER&lt;/span&gt; &lt;span class="k"&gt;STABLE&lt;/span&gt;&lt;span class="p"&gt;;&lt;/span&gt;

&lt;span class="k"&gt;CREATE&lt;/span&gt; &lt;span class="n"&gt;POLICY&lt;/span&gt; &lt;span class="n"&gt;conversations_access&lt;/span&gt; &lt;span class="k"&gt;ON&lt;/span&gt; &lt;span class="n"&gt;conversations&lt;/span&gt;
    &lt;span class="k"&gt;USING&lt;/span&gt; &lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="n"&gt;user_can_access_conversation&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="n"&gt;id&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="n"&gt;current_setting&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="s1"&gt;'app.current_tenant_id'&lt;/span&gt;&lt;span class="p"&gt;)::&lt;/span&gt;&lt;span class="n"&gt;uuid&lt;/span&gt;&lt;span class="p"&gt;));&lt;/span&gt;
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;A rule was born from that bug. A policy must not query another table whose own policy points back to the first. If you need that cross-logic, route it through a &lt;code&gt;SECURITY DEFINER&lt;/code&gt; function.&lt;/p&gt;

&lt;h2&gt;
  
  
  Prove isolation with tests
&lt;/h2&gt;

&lt;p&gt;RLS is a security rule. An untested security rule does not exist. So I write integration tests. They start a real Postgres in a container, with the real policies.&lt;/p&gt;

&lt;p&gt;The test sets tenant A, then tries to read tenant B's data. It must get zero rows. It tries a cross-tenant write. It must be blocked.&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight go"&gt;&lt;code&gt;&lt;span class="c"&gt;// Tenant A sees only its own rows.&lt;/span&gt;
&lt;span class="n"&gt;require&lt;/span&gt;&lt;span class="o"&gt;.&lt;/span&gt;&lt;span class="n"&gt;NoError&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="n"&gt;t&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="n"&gt;db&lt;/span&gt;&lt;span class="o"&gt;.&lt;/span&gt;&lt;span class="n"&gt;SetTenant&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="n"&gt;ctx&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="n"&gt;tenantA&lt;/span&gt;&lt;span class="p"&gt;))&lt;/span&gt;
&lt;span class="n"&gt;repo&lt;/span&gt; &lt;span class="o"&gt;:=&lt;/span&gt; &lt;span class="n"&gt;NewConversationRepo&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="n"&gt;db&lt;/span&gt;&lt;span class="o"&gt;.&lt;/span&gt;&lt;span class="n"&gt;TenantPool&lt;/span&gt;&lt;span class="p"&gt;)&lt;/span&gt;

&lt;span class="n"&gt;got&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="n"&gt;err&lt;/span&gt; &lt;span class="o"&gt;:=&lt;/span&gt; &lt;span class="n"&gt;repo&lt;/span&gt;&lt;span class="o"&gt;.&lt;/span&gt;&lt;span class="n"&gt;List&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="n"&gt;ctx&lt;/span&gt;&lt;span class="p"&gt;)&lt;/span&gt;
&lt;span class="n"&gt;require&lt;/span&gt;&lt;span class="o"&gt;.&lt;/span&gt;&lt;span class="n"&gt;NoError&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="n"&gt;t&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="n"&gt;err&lt;/span&gt;&lt;span class="p"&gt;)&lt;/span&gt;
&lt;span class="n"&gt;require&lt;/span&gt;&lt;span class="o"&gt;.&lt;/span&gt;&lt;span class="n"&gt;Len&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="n"&gt;t&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="n"&gt;got&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="m"&gt;1&lt;/span&gt;&lt;span class="p"&gt;)&lt;/span&gt;            &lt;span class="c"&gt;// only tenant A rows&lt;/span&gt;

&lt;span class="c"&gt;// Tenant A cannot read a tenant B conversation.&lt;/span&gt;
&lt;span class="n"&gt;_&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="n"&gt;err&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="n"&gt;repo&lt;/span&gt;&lt;span class="o"&gt;.&lt;/span&gt;&lt;span class="n"&gt;Get&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="n"&gt;ctx&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="n"&gt;tenantBConversationID&lt;/span&gt;&lt;span class="p"&gt;)&lt;/span&gt;
&lt;span class="n"&gt;require&lt;/span&gt;&lt;span class="o"&gt;.&lt;/span&gt;&lt;span class="n"&gt;Error&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="n"&gt;t&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="n"&gt;err&lt;/span&gt;&lt;span class="p"&gt;)&lt;/span&gt;            &lt;span class="c"&gt;// not found&lt;/span&gt;

&lt;span class="c"&gt;// A write with the wrong tenant is blocked by WITH CHECK.&lt;/span&gt;
&lt;span class="n"&gt;_&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="n"&gt;err&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="n"&gt;repo&lt;/span&gt;&lt;span class="o"&gt;.&lt;/span&gt;&lt;span class="n"&gt;Create&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="n"&gt;ctx&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="o"&gt;&amp;amp;&lt;/span&gt;&lt;span class="n"&gt;Conversation&lt;/span&gt;&lt;span class="p"&gt;{&lt;/span&gt;&lt;span class="n"&gt;TenantID&lt;/span&gt;&lt;span class="o"&gt;:&lt;/span&gt; &lt;span class="n"&gt;tenantB&lt;/span&gt;&lt;span class="p"&gt;})&lt;/span&gt;
&lt;span class="n"&gt;require&lt;/span&gt;&lt;span class="o"&gt;.&lt;/span&gt;&lt;span class="n"&gt;Error&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="n"&gt;t&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="n"&gt;err&lt;/span&gt;&lt;span class="p"&gt;)&lt;/span&gt;
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;I test all four operations: read, create, update, delete. And I check at the raw SQL level, with a &lt;code&gt;count(*)&lt;/code&gt;, that each tenant sees only its rows. These tests run on every commit, in CI.&lt;/p&gt;

&lt;h2&gt;
  
  
  The checklist before production
&lt;/h2&gt;

&lt;p&gt;Before you ship a multi-tenant SaaS on Postgres, check every box.&lt;/p&gt;

&lt;ul&gt;
&lt;li&gt;[ ] Every multi-tenant table has &lt;code&gt;ENABLE&lt;/code&gt; and &lt;code&gt;FORCE ROW LEVEL SECURITY&lt;/code&gt;
&lt;/li&gt;
&lt;li&gt;[ ] Every policy has a &lt;code&gt;USING&lt;/code&gt; for reads and a &lt;code&gt;WITH CHECK&lt;/code&gt; for writes&lt;/li&gt;
&lt;li&gt;[ ] Two roles: an app role under RLS, a system role with &lt;code&gt;BYPASSRLS&lt;/code&gt;
&lt;/li&gt;
&lt;li&gt;[ ] User-facing code always goes through the app role&lt;/li&gt;
&lt;li&gt;[ ] The tenant is set in the pool hook, and reset when the connection returns&lt;/li&gt;
&lt;li&gt;[ ] The tenant comes from the JWT, never from a request parameter&lt;/li&gt;
&lt;li&gt;[ ] A middleware rejects any request with no tenant, with a 403&lt;/li&gt;
&lt;li&gt;[ ] Your code treats "zero rows" as a not-found, not as a successful write&lt;/li&gt;
&lt;li&gt;[ ] No policy queries another table whose policy points back to it&lt;/li&gt;
&lt;li&gt;[ ] Cross-table access logic goes through a &lt;code&gt;SECURITY DEFINER&lt;/code&gt; function&lt;/li&gt;
&lt;li&gt;[ ] Integration tests prove isolation for reads and writes&lt;/li&gt;
&lt;/ul&gt;

&lt;h2&gt;
  
  
  What to remember
&lt;/h2&gt;

&lt;p&gt;Filtering by tenant in Go code works, until the day an &lt;code&gt;if&lt;/code&gt; is missing. RLS moves the wall into the database. There, no code bug can get around it.&lt;/p&gt;

&lt;p&gt;The cost is two roles, one pool hook, and a few traps to know: the silent failure, the forgotten variable, the 42P17 recursion. Once past them, you have isolation that holds on its own.&lt;/p&gt;

&lt;p&gt;Building a multi-tenant SaaS in Go and want a second pair of eyes on your isolation? &lt;a href="https://jrobineau.com/#contact" rel="noopener noreferrer"&gt;That is exactly what I do. Write to me.&lt;/a&gt;&lt;/p&gt;




&lt;p&gt;&lt;em&gt;Originally published at &lt;a href="https://jrobineau.com/blog/2026-07-22-tenant-isolation-postgres-rls-go.html" rel="noopener noreferrer"&gt;jrobineau.com&lt;/a&gt;.&lt;/em&gt;&lt;/p&gt;

&lt;p&gt;&lt;em&gt;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. &lt;a href="https://jrobineau.com/services.html" rel="noopener noreferrer"&gt;Services&lt;/a&gt; · &lt;a href="https://github.com/JulesRobineau" rel="noopener noreferrer"&gt;GitHub&lt;/a&gt; · &lt;a href="https://www.linkedin.com/in/jules-robineau-paris/" rel="noopener noreferrer"&gt;LinkedIn&lt;/a&gt;&lt;/em&gt;&lt;/p&gt;

&lt;p&gt;&lt;strong&gt;Sources:&lt;/strong&gt; &lt;a href="https://www.postgresql.org/docs/current/ddl-rowsecurity.html" rel="noopener noreferrer"&gt;PostgreSQL: Row Security Policies&lt;/a&gt;, &lt;a href="https://www.postgresql.org/docs/current/errcodes-appendix.html" rel="noopener noreferrer"&gt;PostgreSQL: Error Codes (42P17)&lt;/a&gt;, &lt;a href="https://pkg.go.dev/github.com/jackc/pgx/v5/pgxpool" rel="noopener noreferrer"&gt;pgx: pgxpool&lt;/a&gt;, &lt;a href="https://www.crunchydata.com/blog/row-level-security-for-tenants-in-postgres" rel="noopener noreferrer"&gt;Crunchy Data: RLS for Tenants&lt;/a&gt;.&lt;/p&gt;

</description>
      <category>go</category>
      <category>postgres</category>
      <category>security</category>
      <category>database</category>
    </item>
    <item>
      <title>Put the LLM last: I replaced a 7B model with a tiny Go classifier</title>
      <dc:creator>Jules Robineau</dc:creator>
      <pubDate>Thu, 23 Jul 2026 07:00:00 +0000</pubDate>
      <link>https://dev.to/julesrobineau/put-the-llm-last-i-replaced-a-7b-model-with-a-tiny-go-classifier-5d9i</link>
      <guid>https://dev.to/julesrobineau/put-the-llm-last-i-replaced-a-7b-model-with-a-tiny-go-classifier-5d9i</guid>
      <description>&lt;blockquote&gt;
&lt;p&gt;&lt;strong&gt;TL;DR&lt;/strong&gt;: most production AI tasks are not LLM tasks. To triage my email, I replaced a 7-billion-parameter model with a tiny classifier in Go. The rule fits in one sentence. Rules first, a small model next, the LLM only as a last resort. The result: no GPU, sub-millisecond inference, and a cloud call that became rare. Here is how, with the real numbers.&lt;/p&gt;
&lt;/blockquote&gt;

&lt;p&gt;This article is for developers who put an LLM in production and pay the bill. Not a demo.&lt;/p&gt;

&lt;h2&gt;
  
  
  Most AI tasks are not LLM tasks
&lt;/h2&gt;

&lt;p&gt;In 2026, the default reflex is to wire a big model into everything. A question comes in, you call the LLM. But many tasks do not need it. Filing an email under "work" or "newsletter" is classification. A problem solved for twenty years, long before LLMs.&lt;/p&gt;

&lt;p&gt;To classify is to pick a label from a short, stable list. To generate text is something else. The first job needs a small model. The second earns a big one. The rule I defend fits in one sentence. Put the LLM last.&lt;/p&gt;

&lt;h2&gt;
  
  
  The setup
&lt;/h2&gt;

&lt;p&gt;I built an agent that triages my inbox. It is a daemon. It reads new messages and files each one into a category: work, notification, newsletter, promo, and a few more. Nothing secret, just my real mailbox, with years of mail.&lt;/p&gt;

&lt;p&gt;The first version handed every email to a local LLM. A 7-billion-parameter model, Qwen 2.5 7B, served by Ollama on a GPU. Ollama is a tool that runs an LLM on your own machine. It worked. But the price was heavy. A GPU on all the time. One more container to watch. And an absurd slowness for the question asked.&lt;/p&gt;

&lt;p&gt;One day I asked myself: does deciding "is this a newsletter?" really need 7 billion parameters? No. The answer sits in two or three words from the sender and the subject. So I rethought the whole thing.&lt;/p&gt;

&lt;h2&gt;
  
  
  Three layers, from cheapest to most expensive
&lt;/h2&gt;

&lt;p&gt;Every email goes through three layers, in order. It stops at the first one that can answer.&lt;/p&gt;

&lt;ul&gt;
&lt;li&gt;Deterministic rules. Instant, exact, no cost.&lt;/li&gt;
&lt;li&gt;A small model. Sub-millisecond, on CPU.&lt;/li&gt;
&lt;li&gt;The LLM. Only if the small model is unsure.&lt;/li&gt;
&lt;/ul&gt;

&lt;p&gt;The routing code fits in a few lines. It tells the whole story.&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight go"&gt;&lt;code&gt;&lt;span class="k"&gt;func&lt;/span&gt; &lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="n"&gt;c&lt;/span&gt; &lt;span class="o"&gt;*&lt;/span&gt;&lt;span class="n"&gt;Classifier&lt;/span&gt;&lt;span class="p"&gt;)&lt;/span&gt; &lt;span class="n"&gt;decide&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="n"&gt;m&lt;/span&gt; &lt;span class="n"&gt;Message&lt;/span&gt;&lt;span class="p"&gt;)&lt;/span&gt; &lt;span class="n"&gt;Decision&lt;/span&gt; &lt;span class="p"&gt;{&lt;/span&gt;
    &lt;span class="c"&gt;// 1. Deterministic rules: obvious senders, decided at the door.&lt;/span&gt;
    &lt;span class="k"&gt;if&lt;/span&gt; &lt;span class="n"&gt;d&lt;/span&gt; &lt;span class="o"&gt;:=&lt;/span&gt; &lt;span class="n"&gt;preClassify&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="n"&gt;m&lt;/span&gt;&lt;span class="p"&gt;);&lt;/span&gt; &lt;span class="n"&gt;d&lt;/span&gt; &lt;span class="o"&gt;!=&lt;/span&gt; &lt;span class="no"&gt;nil&lt;/span&gt; &lt;span class="p"&gt;{&lt;/span&gt;
        &lt;span class="k"&gt;return&lt;/span&gt; &lt;span class="o"&gt;*&lt;/span&gt;&lt;span class="n"&gt;d&lt;/span&gt;
    &lt;span class="p"&gt;}&lt;/span&gt;
    &lt;span class="c"&gt;// 2. Small ML model: sub-millisecond, on CPU.&lt;/span&gt;
    &lt;span class="n"&gt;pred&lt;/span&gt; &lt;span class="o"&gt;:=&lt;/span&gt; &lt;span class="n"&gt;c&lt;/span&gt;&lt;span class="o"&gt;.&lt;/span&gt;&lt;span class="n"&gt;ml&lt;/span&gt;&lt;span class="o"&gt;.&lt;/span&gt;&lt;span class="n"&gt;Predict&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="n"&gt;m&lt;/span&gt;&lt;span class="p"&gt;)&lt;/span&gt;
    &lt;span class="k"&gt;if&lt;/span&gt; &lt;span class="n"&gt;pred&lt;/span&gt;&lt;span class="o"&gt;.&lt;/span&gt;&lt;span class="n"&gt;Confidence&lt;/span&gt; &lt;span class="o"&gt;&amp;gt;=&lt;/span&gt; &lt;span class="n"&gt;c&lt;/span&gt;&lt;span class="o"&gt;.&lt;/span&gt;&lt;span class="n"&gt;threshold&lt;/span&gt; &lt;span class="p"&gt;{&lt;/span&gt;
        &lt;span class="k"&gt;return&lt;/span&gt; &lt;span class="n"&gt;decisionFrom&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="n"&gt;pred&lt;/span&gt;&lt;span class="p"&gt;)&lt;/span&gt;
    &lt;span class="p"&gt;}&lt;/span&gt;
    &lt;span class="c"&gt;// 3. LLM last: only the uncertain tail reaches the cloud.&lt;/span&gt;
    &lt;span class="k"&gt;return&lt;/span&gt; &lt;span class="n"&gt;c&lt;/span&gt;&lt;span class="o"&gt;.&lt;/span&gt;&lt;span class="n"&gt;classifyWithLLM&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="n"&gt;m&lt;/span&gt;&lt;span class="p"&gt;)&lt;/span&gt;
&lt;span class="p"&gt;}&lt;/span&gt;
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;The logic is simple. Each layer costs more than the one before. So each layer only handles what the others could not decide.&lt;/p&gt;

&lt;h2&gt;
  
  
  Rules skim off the easy mail
&lt;/h2&gt;

&lt;p&gt;A large share of my mail is obvious from the sender alone. A known newsletter address is always a newsletter. A platform alert is always a notification. No intelligence required.&lt;/p&gt;

&lt;p&gt;A short list of rules decides these at once. It looks at the sender domain and sets the label. Zero guessing, zero model call, zero cost. These emails never reach the small model, let alone the LLM.&lt;/p&gt;

&lt;p&gt;Why not let the model do it? Because a rule you can read beats a prediction you cannot, when the answer is obvious. A rule is stable, testable, and free. You keep it for everything that is certain.&lt;/p&gt;

&lt;h2&gt;
  
  
  A small model for the ambiguous middle
&lt;/h2&gt;

&lt;p&gt;For the rest, the part that is not obvious, I use two old techniques. TF-IDF and logistic regression.&lt;/p&gt;

&lt;p&gt;TF-IDF turns text into numbers. Each word gets a weight based on how common or rare it is. Logistic regression is a simple model. It learns to separate categories from those numbers. Together they make a solid, light text classifier.&lt;/p&gt;

&lt;p&gt;I train it in Python, with scikit-learn, on nearly 5,800 labelled emails across 6 categories. Then I export it to a plain JSON file. That file weighs 2.4 MB. Compare it to the 7B model: several gigabytes and a GPU.&lt;/p&gt;

&lt;p&gt;The key point: inference is 100% Go. No Python, no GPU, no C dependency. The Go code reads the JSON and predicts in well under a millisecond, on a plain CPU. It all fits inside my production image, a distroless image with no shell and no system tools.&lt;/p&gt;

&lt;p&gt;And the accuracy? In 5-fold cross-validation, the model reaches 81% correct. Cross-validation splits the data into 5 parts. You train on 4 and test on the 5th, in turn. It is an honest measure, taken on mail never seen during training. The model is strong on the frequent, clear categories, around 0.88. It is weaker on the rare, fuzzy ones, around 0.62.&lt;/p&gt;

&lt;p&gt;Finally, the model returns a confidence, between 0 and 1. Above 0.60, I trust it. Below, the email moves to the next layer.&lt;/p&gt;

&lt;h2&gt;
  
  
  81%, so what?
&lt;/h2&gt;

&lt;p&gt;On its own, 81% looks mediocre. It is not a problem, because of the cascade. No layer has to be perfect. Each layer just has to do what it is good at.&lt;/p&gt;

&lt;p&gt;The rules decide the easy mail, without error. The small model handles the bulk of the middle, with confidence. The LLM only sees the tail, the truly ambiguous mail. The expensive call becomes rare. That is the whole point.&lt;/p&gt;

&lt;p&gt;You are not chasing a perfect model. You are chasing a system where cost follows difficulty. An obvious email costs nothing. A hard email costs one cloud call. And there are few hard emails.&lt;/p&gt;

&lt;h2&gt;
  
  
  The token parity trap
&lt;/h2&gt;

&lt;p&gt;There is one tricky part. The model trains in Python but runs in Go. The way you cut text into tokens must be identical on both sides. Byte for byte.&lt;/p&gt;

&lt;p&gt;A token is a piece of text the model counts, usually a word. If the Go split differs from the Python split, even slightly, the model sees tokens it never learned. Accuracy drops in silence. No error, just worse predictions.&lt;/p&gt;

&lt;p&gt;My fix: a hand-rolled tokenizer, duplicated exactly in both languages. Same regular expression, same table to strip accents, no external unicode library. One explicit table, the same in Python and in Go.&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight go"&gt;&lt;code&gt;&lt;span class="c"&gt;// The same table lives in the Python trainer.&lt;/span&gt;
&lt;span class="c"&gt;// One divergence and the model sees tokens it never learned.&lt;/span&gt;
&lt;span class="k"&gt;var&lt;/span&gt; &lt;span class="n"&gt;fold&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="k"&gt;map&lt;/span&gt;&lt;span class="p"&gt;[&lt;/span&gt;&lt;span class="kt"&gt;rune&lt;/span&gt;&lt;span class="p"&gt;]&lt;/span&gt;&lt;span class="kt"&gt;string&lt;/span&gt;&lt;span class="p"&gt;{&lt;/span&gt;&lt;span class="sc"&gt;'é'&lt;/span&gt;&lt;span class="o"&gt;:&lt;/span&gt; &lt;span class="s"&gt;"e"&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="sc"&gt;'è'&lt;/span&gt;&lt;span class="o"&gt;:&lt;/span&gt; &lt;span class="s"&gt;"e"&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="sc"&gt;'ç'&lt;/span&gt;&lt;span class="o"&gt;:&lt;/span&gt; &lt;span class="s"&gt;"c"&lt;/span&gt; &lt;span class="c"&gt;/* full table */&lt;/span&gt;&lt;span class="p"&gt;}&lt;/span&gt;
&lt;span class="k"&gt;var&lt;/span&gt; &lt;span class="n"&gt;wordRE&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="n"&gt;regexp&lt;/span&gt;&lt;span class="o"&gt;.&lt;/span&gt;&lt;span class="n"&gt;MustCompile&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="s"&gt;`[a-z0-9]{2,}`&lt;/span&gt;&lt;span class="p"&gt;)&lt;/span&gt;

&lt;span class="k"&gt;func&lt;/span&gt; &lt;span class="n"&gt;wordTokens&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="n"&gt;s&lt;/span&gt; &lt;span class="kt"&gt;string&lt;/span&gt;&lt;span class="p"&gt;)&lt;/span&gt; &lt;span class="p"&gt;[]&lt;/span&gt;&lt;span class="kt"&gt;string&lt;/span&gt; &lt;span class="p"&gt;{&lt;/span&gt;
    &lt;span class="k"&gt;var&lt;/span&gt; &lt;span class="n"&gt;b&lt;/span&gt; &lt;span class="n"&gt;strings&lt;/span&gt;&lt;span class="o"&gt;.&lt;/span&gt;&lt;span class="n"&gt;Builder&lt;/span&gt;
    &lt;span class="k"&gt;for&lt;/span&gt; &lt;span class="n"&gt;_&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="n"&gt;r&lt;/span&gt; &lt;span class="o"&gt;:=&lt;/span&gt; &lt;span class="k"&gt;range&lt;/span&gt; &lt;span class="n"&gt;strings&lt;/span&gt;&lt;span class="o"&gt;.&lt;/span&gt;&lt;span class="n"&gt;ToLower&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="n"&gt;s&lt;/span&gt;&lt;span class="p"&gt;)&lt;/span&gt; &lt;span class="p"&gt;{&lt;/span&gt;
        &lt;span class="k"&gt;if&lt;/span&gt; &lt;span class="n"&gt;rep&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="n"&gt;ok&lt;/span&gt; &lt;span class="o"&gt;:=&lt;/span&gt; &lt;span class="n"&gt;fold&lt;/span&gt;&lt;span class="p"&gt;[&lt;/span&gt;&lt;span class="n"&gt;r&lt;/span&gt;&lt;span class="p"&gt;];&lt;/span&gt; &lt;span class="n"&gt;ok&lt;/span&gt; &lt;span class="p"&gt;{&lt;/span&gt;
            &lt;span class="n"&gt;b&lt;/span&gt;&lt;span class="o"&gt;.&lt;/span&gt;&lt;span class="n"&gt;WriteString&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="n"&gt;rep&lt;/span&gt;&lt;span class="p"&gt;)&lt;/span&gt;
        &lt;span class="p"&gt;}&lt;/span&gt; &lt;span class="k"&gt;else&lt;/span&gt; &lt;span class="p"&gt;{&lt;/span&gt;
            &lt;span class="n"&gt;b&lt;/span&gt;&lt;span class="o"&gt;.&lt;/span&gt;&lt;span class="n"&gt;WriteRune&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="n"&gt;r&lt;/span&gt;&lt;span class="p"&gt;)&lt;/span&gt;
        &lt;span class="p"&gt;}&lt;/span&gt;
    &lt;span class="p"&gt;}&lt;/span&gt;
    &lt;span class="k"&gt;return&lt;/span&gt; &lt;span class="n"&gt;wordRE&lt;/span&gt;&lt;span class="o"&gt;.&lt;/span&gt;&lt;span class="n"&gt;FindAllString&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="n"&gt;b&lt;/span&gt;&lt;span class="o"&gt;.&lt;/span&gt;&lt;span class="n"&gt;String&lt;/span&gt;&lt;span class="p"&gt;(),&lt;/span&gt; &lt;span class="o"&gt;-&lt;/span&gt;&lt;span class="m"&gt;1&lt;/span&gt;&lt;span class="p"&gt;)&lt;/span&gt;
&lt;span class="p"&gt;}&lt;/span&gt;
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;A parity test compares the two tokenizers on real text. If they diverge, the build breaks. A red build beats an accuracy that melts without warning.&lt;/p&gt;

&lt;h2&gt;
  
  
  The silent regression that taught me a guard-rail
&lt;/h2&gt;

&lt;p&gt;The model improves through a simple loop. When the agent files an email in the wrong place, I move it to the right folder. That move becomes a new label. I retrain, and the model learns from my correction.&lt;/p&gt;

&lt;p&gt;One day, this loop bit me. I had re-sorted my mail by hand. One category fell below the minimum number of examples needed to learn it. The retrain dropped it in silence. The new model could no longer predict it. Still no error shown.&lt;/p&gt;

&lt;p&gt;The fix: the training script now refuses to drop a category I rely on. It aborts and tells me which folder ran dry.&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight shell"&gt;&lt;code&gt;&lt;span class="c"&gt;# Retraining refuses to lose a category in silence.&lt;/span&gt;
python train.py &lt;span class="nt"&gt;--in&lt;/span&gt; labeled.jsonl &lt;span class="nt"&gt;--out&lt;/span&gt; model.json &lt;span class="se"&gt;\&lt;/span&gt;
    &lt;span class="nt"&gt;--expect-labels&lt;/span&gt; &lt;span class="s2"&gt;"work,notification,newsletter,promo,home"&lt;/span&gt;
&lt;span class="c"&gt;# ABORT: expected classes would be DROPPED from the model: [personal]&lt;/span&gt;
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;The lesson goes beyond email. A silent regression is worse than a crash. A crash, you see. An accuracy that quietly drops, you find out too late. So you make it loud, on purpose.&lt;/p&gt;

&lt;h2&gt;
  
  
  When the LLM earns its place
&lt;/h2&gt;

&lt;p&gt;I did not delete the LLM. I moved it to where it earns its cost. Writing.&lt;/p&gt;

&lt;p&gt;Classifying an email is a closed problem, with few answers. Writing a human reply is an open one. And free language is exactly what a big model does better than anything. So when the agent has to draft a real message, a cloud model handles it.&lt;/p&gt;

&lt;p&gt;Small model for the closed task. Big model for the open task. The right tool at each stage. That is the real lesson, not "LLMs are bad". LLMs are excellent. Just not for everything.&lt;/p&gt;

&lt;h2&gt;
  
  
  The checklist before you reach for an LLM
&lt;/h2&gt;

&lt;blockquote&gt;
&lt;p&gt;Before you call a big model on reflex, run the task through these questions.&lt;/p&gt;
&lt;/blockquote&gt;

&lt;ul&gt;
&lt;li&gt;[ ] Does the task have a small set of stable answers? Then it is classification, not an LLM&lt;/li&gt;
&lt;li&gt;[ ] Can you write rules for the obvious cases? Do them first, they are free&lt;/li&gt;
&lt;li&gt;[ ] Do you have labelled examples? A small model is probably enough&lt;/li&gt;
&lt;li&gt;[ ] Does the model need to understand free language, or just pick a label?&lt;/li&gt;
&lt;li&gt;[ ] Can you measure honest accuracy, with cross-validation?&lt;/li&gt;
&lt;li&gt;[ ] Does the small model run without a GPU, on CPU, inside your production image?&lt;/li&gt;
&lt;li&gt;[ ] Is your tokenizer identical at training and inference, byte for byte?&lt;/li&gt;
&lt;li&gt;[ ] Does a guard-rail prevent a silent regression on retrain?&lt;/li&gt;
&lt;li&gt;[ ] Do you keep the LLM for what it truly does better, generating language?&lt;/li&gt;
&lt;/ul&gt;

&lt;h2&gt;
  
  
  What to remember
&lt;/h2&gt;

&lt;p&gt;The 2026 reflex is the big model for everything. Often, the task does not need it. A cascade costs far less and runs far faster. Rules for the obvious. A small model for the middle. The LLM for the only real difficulty.&lt;/p&gt;

&lt;p&gt;This is not a rejection of AI. It is engineering. You match cost to difficulty, layer by layer. Building an AI system and watching the bill climb? Want to know where a small model would replace your LLM? &lt;a href="https://jrobineau.com/#contact" rel="noopener noreferrer"&gt;That is exactly what I do. Write to me.&lt;/a&gt; Keep the big model for what deserves it.&lt;/p&gt;




&lt;p&gt;&lt;em&gt;Originally published at &lt;a href="https://jrobineau.com/blog/put-the-llm-last.html" rel="noopener noreferrer"&gt;jrobineau.com&lt;/a&gt;.&lt;/em&gt;&lt;/p&gt;

&lt;p&gt;&lt;em&gt;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. &lt;a href="https://jrobineau.com/services.html" rel="noopener noreferrer"&gt;Services&lt;/a&gt; · &lt;a href="https://github.com/JulesRobineau" rel="noopener noreferrer"&gt;GitHub&lt;/a&gt; · &lt;a href="https://www.linkedin.com/in/jules-robineau-paris/" rel="noopener noreferrer"&gt;LinkedIn&lt;/a&gt;&lt;/em&gt;&lt;/p&gt;

&lt;p&gt;&lt;strong&gt;Sources:&lt;/strong&gt; &lt;a href="https://scikit-learn.org/stable/modules/generated/sklearn.feature_extraction.text.TfidfVectorizer.html" rel="noopener noreferrer"&gt;scikit-learn, TfidfVectorizer&lt;/a&gt;, &lt;a href="https://scikit-learn.org/stable/modules/generated/sklearn.linear_model.LogisticRegression.html" rel="noopener noreferrer"&gt;scikit-learn, LogisticRegression&lt;/a&gt;, &lt;a href="https://scikit-learn.org/stable/modules/generated/sklearn.model_selection.cross_val_predict.html" rel="noopener noreferrer"&gt;scikit-learn, cross-validation&lt;/a&gt;&lt;/p&gt;

</description>
      <category>go</category>
      <category>ai</category>
      <category>machinelearning</category>
      <category>llm</category>
    </item>
    <item>
      <title>Never Let the Model Pick the Tenant ID: Securing an LLM Agent in Go</title>
      <dc:creator>Jules Robineau</dc:creator>
      <pubDate>Wed, 22 Jul 2026 13:33:57 +0000</pubDate>
      <link>https://dev.to/julesrobineau/never-let-the-model-pick-the-tenant-id-securing-an-llm-agent-in-go-o6e</link>
      <guid>https://dev.to/julesrobineau/never-let-the-model-pick-the-tenant-id-securing-an-llm-agent-in-go-o6e</guid>
      <description>&lt;blockquote&gt;
&lt;p&gt;&lt;strong&gt;TL;DR&lt;/strong&gt;: 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.&lt;/p&gt;
&lt;/blockquote&gt;

&lt;p&gt;This article is for Go developers who put an LLM agent or an MCP server in production. Not in a demo.&lt;/p&gt;

&lt;h2&gt;
  
  
  Everyone builds MCP servers. Almost nobody secures them.
&lt;/h2&gt;

&lt;p&gt;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:&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight go"&gt;&lt;code&gt;&lt;span class="k"&gt;package&lt;/span&gt; &lt;span class="n"&gt;main&lt;/span&gt;

&lt;span class="k"&gt;import&lt;/span&gt; &lt;span class="p"&gt;(&lt;/span&gt;
    &lt;span class="s"&gt;"context"&lt;/span&gt;
    &lt;span class="s"&gt;"log"&lt;/span&gt;

    &lt;span class="s"&gt;"github.com/modelcontextprotocol/go-sdk/mcp"&lt;/span&gt;
&lt;span class="p"&gt;)&lt;/span&gt;

&lt;span class="k"&gt;type&lt;/span&gt; &lt;span class="n"&gt;EchoInput&lt;/span&gt; &lt;span class="k"&gt;struct&lt;/span&gt; &lt;span class="p"&gt;{&lt;/span&gt;
    &lt;span class="n"&gt;Message&lt;/span&gt; &lt;span class="kt"&gt;string&lt;/span&gt; &lt;span class="s"&gt;`json:"message" jsonschema:"the text to echo back"`&lt;/span&gt;
&lt;span class="p"&gt;}&lt;/span&gt;

&lt;span class="k"&gt;func&lt;/span&gt; &lt;span class="n"&gt;echo&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="n"&gt;ctx&lt;/span&gt; &lt;span class="n"&gt;context&lt;/span&gt;&lt;span class="o"&gt;.&lt;/span&gt;&lt;span class="n"&gt;Context&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="n"&gt;req&lt;/span&gt; &lt;span class="o"&gt;*&lt;/span&gt;&lt;span class="n"&gt;mcp&lt;/span&gt;&lt;span class="o"&gt;.&lt;/span&gt;&lt;span class="n"&gt;CallToolRequest&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="n"&gt;in&lt;/span&gt; &lt;span class="n"&gt;EchoInput&lt;/span&gt;&lt;span class="p"&gt;)&lt;/span&gt; &lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="o"&gt;*&lt;/span&gt;&lt;span class="n"&gt;mcp&lt;/span&gt;&lt;span class="o"&gt;.&lt;/span&gt;&lt;span class="n"&gt;CallToolResult&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="n"&gt;any&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="kt"&gt;error&lt;/span&gt;&lt;span class="p"&gt;)&lt;/span&gt; &lt;span class="p"&gt;{&lt;/span&gt;
    &lt;span class="k"&gt;return&lt;/span&gt; &lt;span class="o"&gt;&amp;amp;&lt;/span&gt;&lt;span class="n"&gt;mcp&lt;/span&gt;&lt;span class="o"&gt;.&lt;/span&gt;&lt;span class="n"&gt;CallToolResult&lt;/span&gt;&lt;span class="p"&gt;{&lt;/span&gt;&lt;span class="n"&gt;Content&lt;/span&gt;&lt;span class="o"&gt;:&lt;/span&gt; &lt;span class="p"&gt;[]&lt;/span&gt;&lt;span class="n"&gt;mcp&lt;/span&gt;&lt;span class="o"&gt;.&lt;/span&gt;&lt;span class="n"&gt;Content&lt;/span&gt;&lt;span class="p"&gt;{&lt;/span&gt;&lt;span class="o"&gt;&amp;amp;&lt;/span&gt;&lt;span class="n"&gt;mcp&lt;/span&gt;&lt;span class="o"&gt;.&lt;/span&gt;&lt;span class="n"&gt;TextContent&lt;/span&gt;&lt;span class="p"&gt;{&lt;/span&gt;&lt;span class="n"&gt;Text&lt;/span&gt;&lt;span class="o"&gt;:&lt;/span&gt; &lt;span class="n"&gt;in&lt;/span&gt;&lt;span class="o"&gt;.&lt;/span&gt;&lt;span class="n"&gt;Message&lt;/span&gt;&lt;span class="p"&gt;}}},&lt;/span&gt; &lt;span class="no"&gt;nil&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="no"&gt;nil&lt;/span&gt;
&lt;span class="p"&gt;}&lt;/span&gt;

&lt;span class="k"&gt;func&lt;/span&gt; &lt;span class="n"&gt;main&lt;/span&gt;&lt;span class="p"&gt;()&lt;/span&gt; &lt;span class="p"&gt;{&lt;/span&gt;
    &lt;span class="n"&gt;s&lt;/span&gt; &lt;span class="o"&gt;:=&lt;/span&gt; &lt;span class="n"&gt;mcp&lt;/span&gt;&lt;span class="o"&gt;.&lt;/span&gt;&lt;span class="n"&gt;NewServer&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="o"&gt;&amp;amp;&lt;/span&gt;&lt;span class="n"&gt;mcp&lt;/span&gt;&lt;span class="o"&gt;.&lt;/span&gt;&lt;span class="n"&gt;Implementation&lt;/span&gt;&lt;span class="p"&gt;{&lt;/span&gt;&lt;span class="n"&gt;Name&lt;/span&gt;&lt;span class="o"&gt;:&lt;/span&gt; &lt;span class="s"&gt;"echo"&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="n"&gt;Version&lt;/span&gt;&lt;span class="o"&gt;:&lt;/span&gt; &lt;span class="s"&gt;"0.1.0"&lt;/span&gt;&lt;span class="p"&gt;},&lt;/span&gt; &lt;span class="no"&gt;nil&lt;/span&gt;&lt;span class="p"&gt;)&lt;/span&gt;
    &lt;span class="n"&gt;mcp&lt;/span&gt;&lt;span class="o"&gt;.&lt;/span&gt;&lt;span class="n"&gt;AddTool&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="n"&gt;s&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="o"&gt;&amp;amp;&lt;/span&gt;&lt;span class="n"&gt;mcp&lt;/span&gt;&lt;span class="o"&gt;.&lt;/span&gt;&lt;span class="n"&gt;Tool&lt;/span&gt;&lt;span class="p"&gt;{&lt;/span&gt;&lt;span class="n"&gt;Name&lt;/span&gt;&lt;span class="o"&gt;:&lt;/span&gt; &lt;span class="s"&gt;"echo"&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="n"&gt;Description&lt;/span&gt;&lt;span class="o"&gt;:&lt;/span&gt; &lt;span class="s"&gt;"Echo a message."&lt;/span&gt;&lt;span class="p"&gt;},&lt;/span&gt; &lt;span class="n"&gt;echo&lt;/span&gt;&lt;span class="p"&gt;)&lt;/span&gt;
    &lt;span class="k"&gt;if&lt;/span&gt; &lt;span class="n"&gt;err&lt;/span&gt; &lt;span class="o"&gt;:=&lt;/span&gt; &lt;span class="n"&gt;s&lt;/span&gt;&lt;span class="o"&gt;.&lt;/span&gt;&lt;span class="n"&gt;Run&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="n"&gt;context&lt;/span&gt;&lt;span class="o"&gt;.&lt;/span&gt;&lt;span class="n"&gt;Background&lt;/span&gt;&lt;span class="p"&gt;(),&lt;/span&gt; &lt;span class="o"&gt;&amp;amp;&lt;/span&gt;&lt;span class="n"&gt;mcp&lt;/span&gt;&lt;span class="o"&gt;.&lt;/span&gt;&lt;span class="n"&gt;StdioTransport&lt;/span&gt;&lt;span class="p"&gt;{});&lt;/span&gt; &lt;span class="n"&gt;err&lt;/span&gt; &lt;span class="o"&gt;!=&lt;/span&gt; &lt;span class="no"&gt;nil&lt;/span&gt; &lt;span class="p"&gt;{&lt;/span&gt;
        &lt;span class="n"&gt;log&lt;/span&gt;&lt;span class="o"&gt;.&lt;/span&gt;&lt;span class="n"&gt;Fatal&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="n"&gt;err&lt;/span&gt;&lt;span class="p"&gt;)&lt;/span&gt;
    &lt;span class="p"&gt;}&lt;/span&gt;
&lt;span class="p"&gt;}&lt;/span&gt;
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;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.&lt;/p&gt;

&lt;p&gt;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.&lt;/p&gt;

&lt;h2&gt;
  
  
  The setup
&lt;/h2&gt;

&lt;p&gt;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.&lt;/p&gt;

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

&lt;p&gt;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.&lt;/p&gt;

&lt;h2&gt;
  
  
  The threat model, in plain words
&lt;/h2&gt;

&lt;p&gt;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.&lt;/p&gt;

&lt;p&gt;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.&lt;/p&gt;

&lt;h2&gt;
  
  
  Identity comes from the server, never from the model
&lt;/h2&gt;

&lt;p&gt;This is rule number 1. Everything else revolves around it.&lt;/p&gt;

&lt;h3&gt;
  
  
  The model proposes, but never picks who it is
&lt;/h3&gt;

&lt;p&gt;First, a word about tenants. In a multi-client app, a tenant is one client's data space. The &lt;code&gt;tenant_id&lt;/code&gt; says who a piece of data belongs to.&lt;/p&gt;

&lt;p&gt;Take a tool like &lt;code&gt;get_record(tenant_id, user_id, record_id)&lt;/code&gt;. When the model calls it, it proposes a &lt;code&gt;tenant_id&lt;/code&gt; 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 &lt;code&gt;your_tenant_id&lt;/code&gt; 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.&lt;/p&gt;

&lt;p&gt;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.&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight go"&gt;&lt;code&gt;&lt;span class="c"&gt;// The model proposes arguments. It does NOT get to choose who it acts as.&lt;/span&gt;
&lt;span class="k"&gt;func&lt;/span&gt; &lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="n"&gt;s&lt;/span&gt; &lt;span class="o"&gt;*&lt;/span&gt;&lt;span class="n"&gt;Service&lt;/span&gt;&lt;span class="p"&gt;)&lt;/span&gt; &lt;span class="n"&gt;prepareToolInput&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="n"&gt;sess&lt;/span&gt; &lt;span class="n"&gt;Session&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="n"&gt;args&lt;/span&gt; &lt;span class="k"&gt;map&lt;/span&gt;&lt;span class="p"&gt;[&lt;/span&gt;&lt;span class="kt"&gt;string&lt;/span&gt;&lt;span class="p"&gt;]&lt;/span&gt;&lt;span class="n"&gt;any&lt;/span&gt;&lt;span class="p"&gt;)&lt;/span&gt; &lt;span class="k"&gt;map&lt;/span&gt;&lt;span class="p"&gt;[&lt;/span&gt;&lt;span class="kt"&gt;string&lt;/span&gt;&lt;span class="p"&gt;]&lt;/span&gt;&lt;span class="n"&gt;any&lt;/span&gt; &lt;span class="p"&gt;{&lt;/span&gt;
    &lt;span class="k"&gt;for&lt;/span&gt; &lt;span class="n"&gt;_&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="n"&gt;key&lt;/span&gt; &lt;span class="o"&gt;:=&lt;/span&gt; &lt;span class="k"&gt;range&lt;/span&gt; &lt;span class="p"&gt;[]&lt;/span&gt;&lt;span class="kt"&gt;string&lt;/span&gt;&lt;span class="p"&gt;{&lt;/span&gt;&lt;span class="s"&gt;"tenant_id"&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="s"&gt;"user_id"&lt;/span&gt;&lt;span class="p"&gt;}&lt;/span&gt; &lt;span class="p"&gt;{&lt;/span&gt;
        &lt;span class="n"&gt;serverVal&lt;/span&gt; &lt;span class="o"&gt;:=&lt;/span&gt; &lt;span class="n"&gt;sess&lt;/span&gt;&lt;span class="o"&gt;.&lt;/span&gt;&lt;span class="n"&gt;Get&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="n"&gt;key&lt;/span&gt;&lt;span class="p"&gt;)&lt;/span&gt; &lt;span class="c"&gt;// resolved from the validated JWT&lt;/span&gt;
        &lt;span class="k"&gt;if&lt;/span&gt; &lt;span class="n"&gt;v&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="n"&gt;ok&lt;/span&gt; &lt;span class="o"&gt;:=&lt;/span&gt; &lt;span class="n"&gt;args&lt;/span&gt;&lt;span class="p"&gt;[&lt;/span&gt;&lt;span class="n"&gt;key&lt;/span&gt;&lt;span class="p"&gt;]&lt;/span&gt;&lt;span class="o"&gt;.&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="kt"&gt;string&lt;/span&gt;&lt;span class="p"&gt;);&lt;/span&gt; &lt;span class="n"&gt;ok&lt;/span&gt; &lt;span class="o"&gt;&amp;amp;&amp;amp;&lt;/span&gt; &lt;span class="n"&gt;v&lt;/span&gt; &lt;span class="o"&gt;!=&lt;/span&gt; &lt;span class="s"&gt;""&lt;/span&gt; &lt;span class="o"&gt;&amp;amp;&amp;amp;&lt;/span&gt; &lt;span class="n"&gt;v&lt;/span&gt; &lt;span class="o"&gt;!=&lt;/span&gt; &lt;span class="n"&gt;serverVal&lt;/span&gt; &lt;span class="p"&gt;{&lt;/span&gt;
            &lt;span class="c"&gt;// Divergent value = hallucination or attack. Log it, then enforce.&lt;/span&gt;
            &lt;span class="n"&gt;s&lt;/span&gt;&lt;span class="o"&gt;.&lt;/span&gt;&lt;span class="n"&gt;log&lt;/span&gt;&lt;span class="o"&gt;.&lt;/span&gt;&lt;span class="n"&gt;Warn&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="s"&gt;"tool_call."&lt;/span&gt;&lt;span class="o"&gt;+&lt;/span&gt;&lt;span class="n"&gt;key&lt;/span&gt;&lt;span class="o"&gt;+&lt;/span&gt;&lt;span class="s"&gt;"_mismatch"&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt;
                &lt;span class="n"&gt;zap&lt;/span&gt;&lt;span class="o"&gt;.&lt;/span&gt;&lt;span class="n"&gt;Int&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="s"&gt;"proposed_len"&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="nb"&gt;len&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="n"&gt;v&lt;/span&gt;&lt;span class="p"&gt;)),&lt;/span&gt; &lt;span class="n"&gt;zap&lt;/span&gt;&lt;span class="o"&gt;.&lt;/span&gt;&lt;span class="n"&gt;String&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="s"&gt;"enforced"&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="n"&gt;serverVal&lt;/span&gt;&lt;span class="p"&gt;))&lt;/span&gt;
        &lt;span class="p"&gt;}&lt;/span&gt;
        &lt;span class="n"&gt;args&lt;/span&gt;&lt;span class="p"&gt;[&lt;/span&gt;&lt;span class="n"&gt;key&lt;/span&gt;&lt;span class="p"&gt;]&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="n"&gt;serverVal&lt;/span&gt; &lt;span class="c"&gt;// the server always wins&lt;/span&gt;
    &lt;span class="p"&gt;}&lt;/span&gt;
    &lt;span class="k"&gt;return&lt;/span&gt; &lt;span class="n"&gt;args&lt;/span&gt;
&lt;span class="p"&gt;}&lt;/span&gt;
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;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.&lt;/p&gt;

&lt;h3&gt;
  
  
  Keep the token away from the model
&lt;/h3&gt;

&lt;p&gt;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.&lt;/p&gt;

&lt;p&gt;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.&lt;/p&gt;

&lt;p&gt;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.&lt;/p&gt;

&lt;p&gt;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.&lt;/p&gt;

&lt;h2&gt;
  
  
  Give each agent a short list of tools
&lt;/h2&gt;

&lt;p&gt;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:&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight go"&gt;&lt;code&gt;&lt;span class="n"&gt;handler&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="n"&gt;ok&lt;/span&gt; &lt;span class="o"&gt;:=&lt;/span&gt; &lt;span class="n"&gt;s&lt;/span&gt;&lt;span class="o"&gt;.&lt;/span&gt;&lt;span class="n"&gt;toolsFor&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="n"&gt;agentType&lt;/span&gt;&lt;span class="p"&gt;)[&lt;/span&gt;&lt;span class="n"&gt;toolName&lt;/span&gt;&lt;span class="p"&gt;]&lt;/span&gt;
&lt;span class="k"&gt;if&lt;/span&gt; &lt;span class="o"&gt;!&lt;/span&gt;&lt;span class="n"&gt;ok&lt;/span&gt; &lt;span class="p"&gt;{&lt;/span&gt;
    &lt;span class="k"&gt;return&lt;/span&gt; &lt;span class="n"&gt;toolError&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="s"&gt;"tool %q not available for this agent"&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="n"&gt;toolName&lt;/span&gt;&lt;span class="p"&gt;)&lt;/span&gt; &lt;span class="c"&gt;// never execute unknown tools&lt;/span&gt;
&lt;span class="p"&gt;}&lt;/span&gt;
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;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.&lt;/p&gt;

&lt;p&gt;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.&lt;/p&gt;

&lt;h2&gt;
  
  
  Put tenant isolation in the database
&lt;/h2&gt;

&lt;p&gt;You can filter data by tenant in your Go code. But one forgotten &lt;code&gt;if&lt;/code&gt; 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:&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight sql"&gt;&lt;code&gt;&lt;span class="k"&gt;ALTER&lt;/span&gt; &lt;span class="k"&gt;TABLE&lt;/span&gt; &lt;span class="n"&gt;conversations&lt;/span&gt; &lt;span class="n"&gt;ENABLE&lt;/span&gt; &lt;span class="k"&gt;ROW&lt;/span&gt; &lt;span class="k"&gt;LEVEL&lt;/span&gt; &lt;span class="k"&gt;SECURITY&lt;/span&gt;&lt;span class="p"&gt;;&lt;/span&gt;
&lt;span class="k"&gt;CREATE&lt;/span&gt; &lt;span class="n"&gt;POLICY&lt;/span&gt; &lt;span class="n"&gt;tenant_isolation&lt;/span&gt; &lt;span class="k"&gt;ON&lt;/span&gt; &lt;span class="n"&gt;conversations&lt;/span&gt;
    &lt;span class="k"&gt;USING&lt;/span&gt;      &lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="n"&gt;tenant_id&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="n"&gt;current_setting&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="s1"&gt;'app.current_tenant_id'&lt;/span&gt;&lt;span class="p"&gt;)::&lt;/span&gt;&lt;span class="n"&gt;uuid&lt;/span&gt;&lt;span class="p"&gt;)&lt;/span&gt;
    &lt;span class="k"&gt;WITH&lt;/span&gt; &lt;span class="k"&gt;CHECK&lt;/span&gt; &lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="n"&gt;tenant_id&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="n"&gt;current_setting&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="s1"&gt;'app.current_tenant_id'&lt;/span&gt;&lt;span class="p"&gt;)::&lt;/span&gt;&lt;span class="n"&gt;uuid&lt;/span&gt;&lt;span class="p"&gt;);&lt;/span&gt;
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;On every connection, my code fills the &lt;code&gt;app.current_tenant_id&lt;/code&gt; 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.&lt;/p&gt;

&lt;p&gt;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.&lt;/p&gt;

&lt;h2&gt;
  
  
  Mask personal data before the model
&lt;/h2&gt;

&lt;p&gt;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:&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight plaintext"&gt;&lt;code&gt;"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)"
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;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 &lt;code&gt;{PH:test}&lt;/code&gt; 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.&lt;/p&gt;

&lt;h2&gt;
  
  
  Give the model a budget, and watch it
&lt;/h2&gt;

&lt;p&gt;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:&lt;/p&gt;

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

&lt;p&gt;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.&lt;/p&gt;

&lt;p&gt;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.&lt;/p&gt;

&lt;p&gt;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 &lt;code&gt;tenant_id&lt;/code&gt;. That line is your smoke detector for prompt injection.&lt;/p&gt;

&lt;h2&gt;
  
  
  Harden the code of every tool
&lt;/h2&gt;

&lt;p&gt;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:&lt;/p&gt;

&lt;ul&gt;
&lt;li&gt;Never glue SQL by hand. I use &lt;code&gt;sqlc&lt;/code&gt;, which generates typed, parameterized queries. SQL injection becomes impossible.&lt;/li&gt;
&lt;li&gt;Never use a shell. Arguments go straight to the program, without &lt;code&gt;sh&lt;/code&gt;.&lt;/li&gt;
&lt;li&gt;Limits everywhere: request size, content size, maximum duration.
&lt;/li&gt;
&lt;/ul&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight go"&gt;&lt;code&gt;&lt;span class="n"&gt;exec&lt;/span&gt;&lt;span class="o"&gt;.&lt;/span&gt;&lt;span class="n"&gt;Command&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="s"&gt;"sh"&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="s"&gt;"-c"&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="s"&gt;"grep "&lt;/span&gt;&lt;span class="o"&gt;+&lt;/span&gt;&lt;span class="n"&gt;in&lt;/span&gt;&lt;span class="o"&gt;.&lt;/span&gt;&lt;span class="n"&gt;Query&lt;/span&gt;&lt;span class="o"&gt;+&lt;/span&gt;&lt;span class="s"&gt;" file"&lt;/span&gt;&lt;span class="p"&gt;)&lt;/span&gt;  &lt;span class="c"&gt;// ❌ shell injection&lt;/span&gt;
&lt;span class="n"&gt;exec&lt;/span&gt;&lt;span class="o"&gt;.&lt;/span&gt;&lt;span class="n"&gt;Command&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="s"&gt;"grep"&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="n"&gt;in&lt;/span&gt;&lt;span class="o"&gt;.&lt;/span&gt;&lt;span class="n"&gt;Query&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="s"&gt;"file"&lt;/span&gt;&lt;span class="p"&gt;)&lt;/span&gt;              &lt;span class="c"&gt;// ✅ args, no shell&lt;/span&gt;
&lt;span class="c"&gt;// SQL: db.QueryContext(ctx, "... WHERE id = $1", id), always parameterized&lt;/span&gt;
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;The tool description guides the model. But your code does the protecting. Validate in the schema. Then validate again in the code.&lt;/p&gt;

&lt;h2&gt;
  
  
  Protect the code before production
&lt;/h2&gt;

&lt;p&gt;All these protections run in production. But security starts earlier, in the repo. In practice, on my side:&lt;/p&gt;

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

&lt;h2&gt;
  
  
  What changes for a real MCP server
&lt;/h2&gt;

&lt;p&gt;Everything above applies to any agent. If you build a real MCP server, add these points:&lt;/p&gt;

&lt;ul&gt;
&lt;li&gt;Locally, use the &lt;code&gt;stdio&lt;/code&gt; transport. For remote, use Streamable HTTP. It replaces the old HTTP+SSE. And as soon as you leave &lt;code&gt;stdio&lt;/code&gt;, authentication becomes mandatory.&lt;/li&gt;
&lt;li&gt;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.&lt;/li&gt;
&lt;li&gt;Do not expose the server by accident. Listen on &lt;code&gt;127.0.0.1&lt;/code&gt; by default. Check the &lt;code&gt;Origin&lt;/code&gt; and &lt;code&gt;Host&lt;/code&gt; headers. And never open the server to the internet without authentication.&lt;/li&gt;
&lt;/ul&gt;

&lt;h2&gt;
  
  
  The pre-production checklist
&lt;/h2&gt;

&lt;blockquote&gt;
&lt;p&gt;Before you put an LLM agent or an MCP server in production, tick every box.&lt;/p&gt;
&lt;/blockquote&gt;

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

&lt;h2&gt;
  
  
  What to remember
&lt;/h2&gt;

&lt;p&gt;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.&lt;/p&gt;

&lt;p&gt;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? &lt;a href="https://jrobineau.com/#contact" rel="noopener noreferrer"&gt;That is exactly what I do. Get in touch.&lt;/a&gt; Ship your agent. Just do not ship the breach with it.&lt;/p&gt;




&lt;p&gt;&lt;em&gt;Originally published at &lt;a href="https://jrobineau.com/blog/mcp-secure-go.html" rel="noopener noreferrer"&gt;jrobineau.com&lt;/a&gt;.&lt;/em&gt;&lt;/p&gt;

&lt;p&gt;&lt;em&gt;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. &lt;a href="https://jrobineau.com/services.html" rel="noopener noreferrer"&gt;Services&lt;/a&gt; · &lt;a href="https://github.com/JulesRobineau" rel="noopener noreferrer"&gt;GitHub&lt;/a&gt; · &lt;a href="https://www.linkedin.com/in/jules-robineau-paris/" rel="noopener noreferrer"&gt;LinkedIn&lt;/a&gt;&lt;/em&gt;&lt;/p&gt;

&lt;p&gt;&lt;strong&gt;Sources:&lt;/strong&gt; &lt;a href="https://modelcontextprotocol.io/specification/draft/basic/authorization" rel="noopener noreferrer"&gt;MCP Authorization spec&lt;/a&gt;, &lt;a href="https://modelcontextprotocol.io/docs/tutorials/security/security_best_practices" rel="noopener noreferrer"&gt;MCP Security Best Practices&lt;/a&gt;, &lt;a href="https://github.com/modelcontextprotocol/go-sdk" rel="noopener noreferrer"&gt;Official Go SDK&lt;/a&gt;, &lt;a href="https://www.anthropic.com/research/agentic-misalignment" rel="noopener noreferrer"&gt;Anthropic Agentic Misalignment&lt;/a&gt;, &lt;a href="https://snyk.io/blog/malicious-mcp-server-on-npm-postmark-mcp-harvests-emails/" rel="noopener noreferrer"&gt;postmark-mcp (Snyk)&lt;/a&gt;, &lt;a href="https://www.pomerium.com/blog/when-ai-has-root-lessons-from-the-supabase-mcp-data-leak" rel="noopener noreferrer"&gt;Supabase MCP leak (Pomerium)&lt;/a&gt;, RFC 8707, RFC 9728.&lt;/p&gt;

</description>
      <category>go</category>
      <category>ai</category>
      <category>security</category>
      <category>devsecops</category>
    </item>
  </channel>
</rss>
