<?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: Vanhpoker</title>
    <description>The latest articles on DEV Community by Vanhpoker (@vanhpoker).</description>
    <link>https://dev.to/vanhpoker</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%2F4120396%2F0d2afddc-f8d8-40c6-9fff-b0f6fbd936b5.png</url>
      <title>DEV Community: Vanhpoker</title>
      <link>https://dev.to/vanhpoker</link>
    </image>
    <atom:link rel="self" type="application/rss+xml" href="https://dev.to/feed/vanhpoker"/>
    <language>en</language>
    <item>
      <title>Your agent's confirm token is one-shot. Your write still happens twice.</title>
      <dc:creator>Vanhpoker</dc:creator>
      <pubDate>Fri, 11 Sep 2026 07:13:54 +0000</pubDate>
      <link>https://dev.to/vanhpoker/your-agents-confirm-token-is-one-shot-your-write-still-happens-twice-2geo</link>
      <guid>https://dev.to/vanhpoker/your-agents-confirm-token-is-one-shot-your-write-still-happens-twice-2geo</guid>
      <description>&lt;p&gt;&lt;em&gt;A production bug I shipped, why the obvious fix was incomplete, and what actually closes it.&lt;/em&gt;&lt;/p&gt;

&lt;h2&gt;
  
  
  The incident
&lt;/h2&gt;

&lt;p&gt;I build an AI assistant for a school-management platform. Teachers and school admins ask it questions in natural language, and it can also perform write operations: create an exam room, correct attendance, and so on. The stack is a LangGraph agent in Python calling an MCP server written in Go. The MCP server exposes 26 tools. Twelve of them write.&lt;/p&gt;

&lt;p&gt;A few months after launch we found duplicate rows. Two exam rooms where the user had created one. Two attendance corrections where the user had made one.&lt;/p&gt;

&lt;p&gt;Nothing in the logs looked wrong. Both writes were authenticated. Both were authorized. Both contained exactly what the user had asked for. There were just two of them.&lt;/p&gt;

&lt;p&gt;The cause was mundane. Sometimes the user clicked again because the response felt slow. Sometimes the model called the same tool twice inside one turn. The user approved one action. The system performed two.&lt;/p&gt;

&lt;h2&gt;
  
  
  The first fix
&lt;/h2&gt;

&lt;p&gt;I split every write tool into two phases.&lt;/p&gt;

&lt;p&gt;The first call does not write anything. It returns a preview of what will happen, plus a confirmation token. The second call replays that token, and only then does the write happen.&lt;/p&gt;

&lt;p&gt;The token is an HMAC. It is bound to the user ID, the tool name, and a hash of the parameters. It expires after five minutes. The nonce inside it is burned the moment it is used.&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight python"&gt;&lt;code&gt;&lt;span class="c1"&gt;# phase 1 - preview only
&lt;/span&gt;&lt;span class="n"&gt;token&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="nf"&gt;hmac&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="n"&gt;secret&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="sa"&gt;f&lt;/span&gt;&lt;span class="sh"&gt;"&lt;/span&gt;&lt;span class="si"&gt;{&lt;/span&gt;&lt;span class="n"&gt;user_id&lt;/span&gt;&lt;span class="si"&gt;}&lt;/span&gt;&lt;span class="s"&gt;|&lt;/span&gt;&lt;span class="si"&gt;{&lt;/span&gt;&lt;span class="n"&gt;tool&lt;/span&gt;&lt;span class="si"&gt;}&lt;/span&gt;&lt;span class="s"&gt;|&lt;/span&gt;&lt;span class="si"&gt;{&lt;/span&gt;&lt;span class="nf"&gt;sha256&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="n"&gt;params&lt;/span&gt;&lt;span class="p"&gt;)&lt;/span&gt;&lt;span class="si"&gt;}&lt;/span&gt;&lt;span class="s"&gt;|&lt;/span&gt;&lt;span class="si"&gt;{&lt;/span&gt;&lt;span class="n"&gt;nonce&lt;/span&gt;&lt;span class="si"&gt;}&lt;/span&gt;&lt;span class="s"&gt;|&lt;/span&gt;&lt;span class="si"&gt;{&lt;/span&gt;&lt;span class="n"&gt;exp&lt;/span&gt;&lt;span class="si"&gt;}&lt;/span&gt;&lt;span class="sh"&gt;"&lt;/span&gt;&lt;span class="p"&gt;)&lt;/span&gt;
&lt;span class="k"&gt;return&lt;/span&gt; &lt;span class="p"&gt;{&lt;/span&gt;&lt;span class="sh"&gt;"&lt;/span&gt;&lt;span class="s"&gt;preview&lt;/span&gt;&lt;span class="sh"&gt;"&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="nf"&gt;render&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="n"&gt;params&lt;/span&gt;&lt;span class="p"&gt;),&lt;/span&gt; &lt;span class="sh"&gt;"&lt;/span&gt;&lt;span class="s"&gt;confirm_token&lt;/span&gt;&lt;span class="sh"&gt;"&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="n"&gt;token&lt;/span&gt;&lt;span class="p"&gt;}&lt;/span&gt;

&lt;span class="c1"&gt;# phase 2 - execute
&lt;/span&gt;&lt;span class="k"&gt;assert&lt;/span&gt; &lt;span class="nf"&gt;verify_hmac&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="n"&gt;token&lt;/span&gt;&lt;span class="p"&gt;)&lt;/span&gt;
&lt;span class="k"&gt;assert&lt;/span&gt; &lt;span class="nf"&gt;burn_nonce&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="n"&gt;token&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="n"&gt;nonce&lt;/span&gt;&lt;span class="p"&gt;)&lt;/span&gt;   &lt;span class="c1"&gt;# fails if already used
&lt;/span&gt;&lt;span class="nf"&gt;write&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="n"&gt;params&lt;/span&gt;&lt;span class="p"&gt;)&lt;/span&gt;
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;The parameter hash matters. Without it, the model could show the user one thing and execute another. With it, what the user approved is exactly what runs.&lt;/p&gt;

&lt;p&gt;I also strip the token out of the SSE stream before it reaches the browser. The model never sees it. The user never sees it.&lt;/p&gt;

&lt;p&gt;This worked. The duplicate writes stopped. I considered the problem solved for a long time.&lt;/p&gt;

&lt;h2&gt;
  
  
  What I missed
&lt;/h2&gt;

&lt;p&gt;The nonce protects the token. It does not protect the action.&lt;/p&gt;

&lt;p&gt;Burning a nonce means one specific token cannot be used twice. But the agent does not need the old token. The agent re-plans.&lt;/p&gt;

&lt;p&gt;It calls phase one again, for the same action. It receives a new token. A completely valid one. Then it replays that token, and the write happens a second time.&lt;/p&gt;

&lt;p&gt;Walk through the checks with me. HMAC signature: valid, it is a fresh token. Nonce: unused, it is a fresh nonce. Expiry: fine, it was issued a second ago. Parameter hash: matches, because it is the same action with the same parameters.&lt;/p&gt;

&lt;p&gt;Every check passes. That is the uncomfortable part. The parameter hash matching is the strongest evidence available that this is a duplicate, and my design treated it as proof of correctness instead.&lt;/p&gt;

&lt;p&gt;The root cause is that my state was keyed by the token, not by the action. The issuer remembered which tokens had been spent. It remembered nothing about which actions had been performed.&lt;/p&gt;

&lt;p&gt;Recent work has a name for this: &lt;strong&gt;semantic replay&lt;/strong&gt;. Xu et al. define it as "exceeding the execution budget of a token-independent authorization instance rather than merely reusing an old token identifier" (&lt;a href="https://arxiv.org/abs/2608.01710" rel="noopener noreferrer"&gt;Beyond Single-Use Tokens, arXiv:2608.01710&lt;/a&gt;). Their phrase for the failure mode is precise: identifier-local tokens permit fresh semantic reissuance.&lt;/p&gt;

&lt;p&gt;That is exactly what my design did. It made reissuance cheap and invisible.&lt;/p&gt;

&lt;h2&gt;
  
  
  Why more token hardening does not help
&lt;/h2&gt;

&lt;p&gt;The instinct is to tighten the token. It does not work.&lt;/p&gt;

&lt;p&gt;A shorter expiry does not help, because the second token is brand new. A stronger nonce does not help, because no nonce is reused. Signing more fields does not help, because every field is legitimately identical.&lt;/p&gt;

&lt;p&gt;Asking the user to confirm again feels like a fix, and it is the worst one. In a chat interface, the second confirmation prompt looks exactly like the first. The user clicks yes. That is not oversight. That is a rubber stamp with an audit log attached.&lt;/p&gt;

&lt;p&gt;Every fix at the token layer protects the token. The problem is not at the token layer.&lt;/p&gt;

&lt;h2&gt;
  
  
  What actually closes it
&lt;/h2&gt;

&lt;p&gt;Key the state to the action.&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight python"&gt;&lt;code&gt;&lt;span class="n"&gt;action_key&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="nf"&gt;sha256&lt;/span&gt;&lt;span class="p"&gt;(&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;tool_name&lt;/span&gt; &lt;span class="o"&gt;|&lt;/span&gt; &lt;span class="nf"&gt;canonical&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="n"&gt;params&lt;/span&gt;&lt;span class="p"&gt;)&lt;/span&gt; &lt;span class="o"&gt;|&lt;/span&gt; &lt;span class="n"&gt;turn_id&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;canonical(params)&lt;/code&gt; is not optional. Sort the keys, normalise the types. Otherwise &lt;code&gt;{"a":1,"b":2}&lt;/code&gt; and &lt;code&gt;{"b":2,"a":1}&lt;/code&gt; produce different keys and the mechanism does nothing.&lt;/p&gt;

&lt;p&gt;Then keep a durable record per action key, and let the state move in one direction only.&lt;/p&gt;

&lt;ul&gt;
&lt;li&gt;No record: create one in &lt;code&gt;ISSUED&lt;/code&gt; state, return a new token.&lt;/li&gt;
&lt;li&gt;Already &lt;code&gt;ISSUED&lt;/code&gt;, not committed: return &lt;strong&gt;the same token again&lt;/strong&gt;. Issuing is idempotent. You do not hand out a second authorization for an action that already has one.&lt;/li&gt;
&lt;li&gt;Already &lt;code&gt;COMMITTED&lt;/code&gt;: refuse, and return the result of the first execution.&lt;/li&gt;
&lt;/ul&gt;

&lt;p&gt;That third case is the one people forget. A duplicate request should not fail loudly. It should return what happened the first time.&lt;/p&gt;

&lt;p&gt;Commit is a transaction. Flipping &lt;code&gt;ISSUED&lt;/code&gt; to &lt;code&gt;COMMITTED&lt;/code&gt; and performing the write happen together, or neither happens.&lt;/p&gt;

&lt;p&gt;In my case the write goes out over HTTP, to the same REST endpoints the browser uses, so a shared database transaction is not available. The answer there is to derive an idempotency key from the action key and pass it to that endpoint.&lt;/p&gt;

&lt;p&gt;Two layers, doing two different jobs. The ledger prevents duplicate &lt;strong&gt;admission&lt;/strong&gt;. The idempotency key prevents duplicate &lt;strong&gt;effects&lt;/strong&gt;. You need both, because the ledger can commit and the HTTP call underneath it can still be retried.&lt;/p&gt;

&lt;p&gt;If this shape feels familiar, it should. It is at-least-once delivery with idempotent consumers. Agent frameworks are rediscovering a problem that message queues solved twenty years ago.&lt;/p&gt;

&lt;h2&gt;
  
  
  The cost
&lt;/h2&gt;

&lt;p&gt;There is a trade-off here, and I would rather state it than hide it.&lt;/p&gt;

&lt;p&gt;Including &lt;code&gt;turn_id&lt;/code&gt; in the action key means a user can genuinely create two identical exam rooms in two different turns. That is correct behaviour. It is also a gap.&lt;/p&gt;

&lt;p&gt;Removing &lt;code&gt;turn_id&lt;/code&gt; closes the gap and breaks legitimate repetition.&lt;/p&gt;

&lt;p&gt;There is no universally correct answer. There is only an explicit one. The choice I favour: keep &lt;code&gt;turn_id&lt;/code&gt;, and when two action keys differ only by the turn while the parameters are identical inside a short window, surface a warning to the user instead of silently proceeding.&lt;/p&gt;

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

&lt;p&gt;The risk in agent systems sits in the authorization layer and in the side effects, not in the model.&lt;/p&gt;

&lt;p&gt;Human-in-the-loop tells you that an action was approved. It does not tell you that the action happened once. Those are different guarantees, and they are constantly confused.&lt;/p&gt;

&lt;p&gt;So ask your stack one question. If the model re-plans, can it obtain a second valid approval for the same action? If you do not know the answer, it can.&lt;/p&gt;

</description>
      <category>ai</category>
      <category>security</category>
      <category>llm</category>
      <category>architecture</category>
    </item>
  </channel>
</rss>
