<?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: Shola Jegede</title>
    <description>The latest articles on DEV Community by Shola Jegede (@sholajegede).</description>
    <link>https://dev.to/sholajegede</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%2F1705040%2F3266d189-ef54-4f0e-ae81-ecabae5aac1c.jpg</url>
      <title>DEV Community: Shola Jegede</title>
      <link>https://dev.to/sholajegede</link>
    </image>
    <atom:link rel="self" type="application/rss+xml" href="https://dev.to/feed/sholajegede"/>
    <language>en</language>
    <item>
      <title>How to Stop an AI Agent That Lies About Its Own Spending</title>
      <dc:creator>Shola Jegede</dc:creator>
      <pubDate>Wed, 23 Sep 2026 00:54:29 +0000</pubDate>
      <link>https://dev.to/sholajegede/how-to-stop-an-ai-agent-that-lies-about-its-own-spending-29k2</link>
      <guid>https://dev.to/sholajegede/how-to-stop-an-ai-agent-that-lies-about-its-own-spending-29k2</guid>
      <description>&lt;p&gt;In September 2026, Mandiant described a failure in its AI Risk and Resilience report, drawing on data from Google's Threat Intelligence Group. An accounting agent entered a runaway loop where it fired more than 15,000 API calls, burning about $50,000 in cloud spend in under an hour, and yet no attacker had touched it.&lt;/p&gt;

&lt;p&gt;This failure points at a specific gap that we will discuss in this article. A token issued to an AI agent proves who issued it, but tt does not say who owns the spending it causes, or how much that owner has left to spend.&lt;/p&gt;

&lt;p&gt;To reproduce the failure mode described by Mandiant, we will be building a demo application with two products: &lt;a href="https://kinde.com/?utm_source=devto&amp;amp;utm_medium=content&amp;amp;utm_campaign=shola&amp;amp;campaignid=chatgptapp&amp;amp;network=&amp;amp;adgroup=&amp;amp;keyword=&amp;amp;matchtype=&amp;amp;creative=26&amp;amp;device=&amp;amp;adposition=" rel="noopener noreferrer"&gt;Kinde&lt;/a&gt;, which is an identity provider that issues signed access tokens for machine callers and can attach custom data to them, and &lt;a href="https://convex.dev/referral/SHOLAJ7497" rel="noopener noreferrer"&gt;Convex&lt;/a&gt;, a backend platform with a reactive database that runs the server that checks every call.&lt;/p&gt;

&lt;h2&gt;
  
  
  The map, in one screen
&lt;/h2&gt;

&lt;p&gt;&lt;a href="https://media2.dev.to/dynamic/image/width=800%2Cheight=%2Cfit=scale-down%2Cgravity=auto%2Cformat=auto/https%3A%2F%2Fdev-to-uploads.s3.us-east-2.amazonaws.com%2Fuploads%2Farticles%2Fu5s5jxo8fud7hcbx739e.png" class="article-body-image-wrapper"&gt;&lt;img src="https://media2.dev.to/dynamic/image/width=800%2Cheight=%2Cfit=scale-down%2Cgravity=auto%2Cformat=auto/https%3A%2F%2Fdev-to-uploads.s3.us-east-2.amazonaws.com%2Fuploads%2Farticles%2Fu5s5jxo8fud7hcbx739e.png" alt="Three tokens, one enforcement point" width="800" height="402"&gt;&lt;/a&gt;&lt;br&gt;
&lt;em&gt;In our demo, three agents will call the same Convex route. What changes is which claims their Kinde token carries, and whether the server checks those claims against its own ledger or against the request itself.&lt;/em&gt;&lt;/p&gt;

&lt;p&gt;One agent's token carries no owner and no spend limit. The server treats it as unattributed and uncapped. A second agent's token carries both claims. The server reads them from the verified token and checks them against a running total it keeps in Convex. A third agent reuses the second agent's own token. The server still verifies the signature, but its enforcement route reads the spend total from the request instead of the ledger.&lt;/p&gt;

&lt;p&gt;During the test, both agents made the same calls, one at a time, against a real Kinde tenant and a real Convex deployment, each call priced at $2.50. The unmetered agent made 8 calls. All 8 went through, for $20 spent, with no ceiling in sight. The metered agent made the same calls with a $10 limit set on its token. The first 4 went through, taking it to exactly $10. The server denied every call after that with a 402.&lt;/p&gt;

&lt;p&gt;&lt;a href="https://media2.dev.to/dynamic/image/width=800%2Cheight=%2Cfit=scale-down%2Cgravity=auto%2Cformat=auto/https%3A%2F%2Fdev-to-uploads.s3.us-east-2.amazonaws.com%2Fuploads%2Farticles%2Fdgqe4doi19bag1w3320n.png" class="article-body-image-wrapper"&gt;&lt;img src="https://media2.dev.to/dynamic/image/width=800%2Cheight=%2Cfit=scale-down%2Cgravity=auto%2Cformat=auto/https%3A%2F%2Fdev-to-uploads.s3.us-east-2.amazonaws.com%2Fuploads%2Farticles%2Fdgqe4doi19bag1w3320n.png" alt="Live dashboard showing the unmetered, metered, and naive control agents" width="799" height="404"&gt;&lt;/a&gt;&lt;br&gt;
&lt;em&gt;The live dashboard mid-run: the unmetered agent's total climbing past $20, the metered agent capped at $10, and the naive control still reporting $0 claimed spend.&lt;/em&gt;&lt;/p&gt;
&lt;h2&gt;
  
  
  Why doesn't a valid signature stop overspending?
&lt;/h2&gt;

&lt;p&gt;A JSON Web Token, or JWT, is a signed, verifiable claim. Kinde signs every access token it issues. Any server that trusts Kinde can check that signature without calling Kinde back.&lt;/p&gt;

&lt;p&gt;A valid signature goes on to answer one question: did Kinde issue this token, and has it expired? It does not answer the second question that we have for this test: has this agent already spent its budget? And that second question needs its own check, written into the server, on top of signature verification.&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight typescript"&gt;&lt;code&gt;&lt;span class="kd"&gt;const&lt;/span&gt; &lt;span class="p"&gt;{&lt;/span&gt; &lt;span class="nx"&gt;payload&lt;/span&gt; &lt;span class="p"&gt;}&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="k"&gt;await&lt;/span&gt; &lt;span class="nf"&gt;jwtVerify&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="nx"&gt;token&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="nf"&gt;getJwks&lt;/span&gt;&lt;span class="p"&gt;(),&lt;/span&gt; &lt;span class="p"&gt;{&lt;/span&gt;
  &lt;span class="na"&gt;issuer&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="nf"&gt;requiredEnv&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="dl"&gt;"&lt;/span&gt;&lt;span class="s2"&gt;KINDE_ISSUER&lt;/span&gt;&lt;span class="dl"&gt;"&lt;/span&gt;&lt;span class="p"&gt;),&lt;/span&gt;
  &lt;span class="na"&gt;audience&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="nf"&gt;requiredEnv&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="dl"&gt;"&lt;/span&gt;&lt;span class="s2"&gt;KINDE_M2M_AUDIENCE&lt;/span&gt;&lt;span class="dl"&gt;"&lt;/span&gt;&lt;span class="p"&gt;),&lt;/span&gt;
&lt;span class="p"&gt;});&lt;/span&gt;
&lt;span class="kd"&gt;const&lt;/span&gt; &lt;span class="nx"&gt;mode&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="nf"&gt;modeForClientId&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="nx"&gt;payload&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nx"&gt;azp&lt;/span&gt; &lt;span class="k"&gt;as&lt;/span&gt; &lt;span class="kr"&gt;string&lt;/span&gt; &lt;span class="o"&gt;|&lt;/span&gt; &lt;span class="kc"&gt;undefined&lt;/span&gt;&lt;span class="p"&gt;);&lt;/span&gt;
&lt;span class="kd"&gt;const&lt;/span&gt; &lt;span class="nx"&gt;owner&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="nf"&gt;readProperty&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="nx"&gt;payload&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nx"&gt;application_properties&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="dl"&gt;"&lt;/span&gt;&lt;span class="s2"&gt;agent_owner&lt;/span&gt;&lt;span class="dl"&gt;"&lt;/span&gt;&lt;span class="p"&gt;)&lt;/span&gt; &lt;span class="o"&gt;??&lt;/span&gt; &lt;span class="dl"&gt;"&lt;/span&gt;&lt;span class="s2"&gt;unattributed&lt;/span&gt;&lt;span class="dl"&gt;"&lt;/span&gt;&lt;span class="p"&gt;;&lt;/span&gt;
&lt;span class="kd"&gt;const&lt;/span&gt; &lt;span class="nx"&gt;spendLimitRaw&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="nf"&gt;readProperty&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="nx"&gt;payload&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nx"&gt;application_properties&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="dl"&gt;"&lt;/span&gt;&lt;span class="s2"&gt;agent_spend_limit_usd&lt;/span&gt;&lt;span class="dl"&gt;"&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;jwtVerify&lt;/code&gt; throws if the signature is wrong or the token expired. Everything after that line is this build's own code, not Kinde's. It reads two claims off the verified token: who owns this call, and what that owner's limit is.&lt;/p&gt;

&lt;h2&gt;
  
  
  How does Kinde attach an owner and a limit to a token?
&lt;/h2&gt;

&lt;p&gt;Kinde stores custom data on an M2M application as Properties. A Property has one of three types: single line text, multi line text, or boolean. There is no number type, so a spend limit gets stored as text and parsed with &lt;code&gt;Number()&lt;/code&gt; on the server.&lt;/p&gt;

&lt;p&gt;A Property must be scoped to Applications to attach to an M2M app. Its Private toggle must be off, or it never appears in a token. From there, the application's Properties tab sets a value, and its Tokens tab, under Token customization, turns that Property on for the M2M token.&lt;/p&gt;

&lt;p&gt;&lt;a href="https://media2.dev.to/dynamic/image/width=800%2Cheight=%2Cfit=scale-down%2Cgravity=auto%2Cformat=auto/https%3A%2F%2Fdev-to-uploads.s3.us-east-2.amazonaws.com%2Fuploads%2Farticles%2Fvwlrlj113t89m725qhns.png" class="article-body-image-wrapper"&gt;&lt;img src="https://media2.dev.to/dynamic/image/width=800%2Cheight=%2Cfit=scale-down%2Cgravity=auto%2Cformat=auto/https%3A%2F%2Fdev-to-uploads.s3.us-east-2.amazonaws.com%2Fuploads%2Farticles%2Fvwlrlj113t89m725qhns.png" alt="Kinde Properties tab for the unmetered application, with no properties added" width="799" height="430"&gt;&lt;/a&gt;&lt;br&gt;
&lt;em&gt;The unmetered application's Properties tab: empty, so the server treats every call from it as unattributed and uncapped.&lt;/em&gt;&lt;/p&gt;

&lt;p&gt;&lt;a href="https://media2.dev.to/dynamic/image/width=800%2Cheight=%2Cfit=scale-down%2Cgravity=auto%2Cformat=auto/https%3A%2F%2Fdev-to-uploads.s3.us-east-2.amazonaws.com%2Fuploads%2Farticles%2Ftc5sz9ehkjci38g4aqrv.png" class="article-body-image-wrapper"&gt;&lt;img src="https://media2.dev.to/dynamic/image/width=800%2Cheight=%2Cfit=scale-down%2Cgravity=auto%2Cformat=auto/https%3A%2F%2Fdev-to-uploads.s3.us-east-2.amazonaws.com%2Fuploads%2Farticles%2Ftc5sz9ehkjci38g4aqrv.png" alt="Kinde Token customization toggle for the metered application, with the owner and spend-limit properties turned on" width="799" height="430"&gt;&lt;/a&gt;&lt;br&gt;
&lt;em&gt;The metered application's Token customization tab: both the owner and spend-limit properties switched on, so they ride along in every access token this app issues.&lt;/em&gt;&lt;/p&gt;

&lt;p&gt;A token that carries these claims does not carry them as plain values. Each one arrives wrapped:&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;"agent_owner"&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;"v"&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt;&lt;span class="w"&gt; &lt;/span&gt;&lt;span class="s2"&gt;"finance-ops"&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;"agent_spend_limit_usd"&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;"v"&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt;&lt;span class="w"&gt; &lt;/span&gt;&lt;span class="s2"&gt;"10.00"&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 server has to read &lt;code&gt;.v&lt;/code&gt; off each claim, not the claim itself.&lt;/p&gt;

&lt;h2&gt;
  
  
  What happens when the server trusts the caller instead of the ledger?
&lt;/h2&gt;

&lt;p&gt;The metered agent's enforcement route reads its spend total from Convex. While, a second route in this build, built only to test the failure mode, reads the spend total from a &lt;code&gt;claimedSpend&lt;/code&gt; query parameter on the request instead.&lt;/p&gt;

&lt;p&gt;Both routes check the same math against the same kind of token. Only the source of the number changes.&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight typescript"&gt;&lt;code&gt;&lt;span class="c1"&gt;// The metered agent's route, checked against the ledger Convex itself wrote&lt;/span&gt;
&lt;span class="nx"&gt;allowed&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="nx"&gt;currentTotal&lt;/span&gt; &lt;span class="o"&gt;+&lt;/span&gt; &lt;span class="nx"&gt;args&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nx"&gt;costUsd&lt;/span&gt; &lt;span class="o"&gt;&amp;lt;=&lt;/span&gt; &lt;span class="nx"&gt;args&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nx"&gt;spendLimitUsd&lt;/span&gt;&lt;span class="p"&gt;;&lt;/span&gt;

&lt;span class="c1"&gt;// The naive route, checked against what the caller claims&lt;/span&gt;
&lt;span class="nx"&gt;allowed&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="nx"&gt;args&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nx"&gt;claimedSpendUsd&lt;/span&gt; &lt;span class="o"&gt;+&lt;/span&gt; &lt;span class="nx"&gt;args&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nx"&gt;costUsd&lt;/span&gt; &lt;span class="o"&gt;&amp;lt;=&lt;/span&gt; &lt;span class="nx"&gt;args&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nx"&gt;spendLimitUsd&lt;/span&gt;&lt;span class="p"&gt;;&lt;/span&gt;
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;The test for the naive route fired 20 calls at it with the metered agent's own token, each one claiming $0 already spent. Each call cost $2.50. The ledger was reset to zero before the run, so the result could not be explained by leftover balance from an earlier session.&lt;/p&gt;

&lt;p&gt;&lt;a href="https://media2.dev.to/dynamic/image/width=800%2Cheight=%2Cfit=scale-down%2Cgravity=auto%2Cformat=auto/https%3A%2F%2Fdev-to-uploads.s3.us-east-2.amazonaws.com%2Fuploads%2Farticles%2Fj1fa95mddp42snccgdsf.png" class="article-body-image-wrapper"&gt;&lt;img src="https://media2.dev.to/dynamic/image/width=800%2Cheight=%2Cfit=scale-down%2Cgravity=auto%2Cformat=auto/https%3A%2F%2Fdev-to-uploads.s3.us-east-2.amazonaws.com%2Fuploads%2Farticles%2Fj1fa95mddp42snccgdsf.png" alt="Naive versus ledger-enforced, side by side" width="800" height="284"&gt;&lt;/a&gt;&lt;/p&gt;

&lt;p&gt;&lt;a href="https://media2.dev.to/dynamic/image/width=800%2Cheight=%2Cfit=scale-down%2Cgravity=auto%2Cformat=auto/https%3A%2F%2Fdev-to-uploads.s3.us-east-2.amazonaws.com%2Fuploads%2Farticles%2Fb4yhyw1soor2v3z8xmvp.png" class="article-body-image-wrapper"&gt;&lt;img src="https://media2.dev.to/dynamic/image/width=800%2Cheight=%2Cfit=scale-down%2Cgravity=auto%2Cformat=auto/https%3A%2F%2Fdev-to-uploads.s3.us-east-2.amazonaws.com%2Fuploads%2Farticles%2Fb4yhyw1soor2v3z8xmvp.png" alt="Naive versus ledger-enforced, side by side" width="800" height="435"&gt;&lt;/a&gt;&lt;br&gt;
&lt;em&gt;The naive route trusts the number in the request. The metered route reads the number it wrote itself.&lt;/em&gt;&lt;/p&gt;

&lt;p&gt;All 20 calls returned 200. The claimed spend stayed $0.00 the whole time. Real spend, tracked in the background by the same ledger the metered route uses, reached $50.00 against the agent's own $10.00 limit.&lt;/p&gt;

&lt;p&gt;A clean result here could lie two ways. If the route never wrote to the ledger at all, a $0 claimed-spend result would only mean nothing got recorded, not that the check passed. If the test forgot to reset the ledger before the run, an early denial could look like the limit working when it was really an old balance carried over. This build hit exactly that second bug once. An early version of the metered agent's own proof script reused a ledger balance left over from a previous run, and the agent looked capped from call one, before any of the fix under test had run. Resetting the ledger before every run fixed it.&lt;/p&gt;

&lt;h2&gt;
  
  
  Does concurrency break the count?
&lt;/h2&gt;

&lt;p&gt;The metered agent's ledger check runs inside a Convex mutation. Convex queues mutations and runs them one at a time, even when many calls arrive at once. That queuing should keep the total correct under concurrent load. It says nothing about which specific calls get to be part of that total.&lt;/p&gt;

&lt;p&gt;The concurrency test settled that directly. It fired 20 calls at the real, ledger-enforced route at the same instant, using the same $10 limit and $2.50 cost as the sequential test. The math says exactly 4 calls should be allowed, since 4 times $2.50 is $10.&lt;/p&gt;

&lt;p&gt;Exactly 4 calls were allowed. The count was correct on every run.&lt;/p&gt;

&lt;p&gt;The 4 calls that won were not the first 4 to arrive. They were calls 20, 7, 5, and 19, in that order, based on the order Convex actually processed them in. If a system assumed a correct total also meant first-come-first-served fairness, this build would have proven that assumption wrong. Convex's mutation queue guarantees the total. It does not guarantee arrival order.&lt;/p&gt;

&lt;h2&gt;
  
  
  Limitations
&lt;/h2&gt;

&lt;p&gt;This build ran on one Convex deployment. It did not test enforcement split across multiple deployments or regions.&lt;/p&gt;

&lt;p&gt;Kinde stores the spend limit as text, not a number. The server parses it before comparing it, and a malformed value would need its own check. However, this build assumes an operator sets the Property correctly.&lt;/p&gt;

&lt;p&gt;The mutation that resets a ledger before each proof run exists only for testing. No production request path in this build calls it.&lt;/p&gt;

&lt;p&gt;The naive route exists only inside this demo. Kinde did not cause it, and nothing about Kinde's tokens encourages it. This build's own server code chose to trust a claimed number instead of its own ledger, on purpose, to show what that choice costs.&lt;/p&gt;

&lt;p&gt;The concurrency test fired 20 calls at once. It did not test hundreds or thousands of simultaneous calls, or calls arriving across more than one server instance.&lt;/p&gt;

&lt;h2&gt;
  
  
  Where this leaves things
&lt;/h2&gt;

&lt;p&gt;A signed token proves Kinde issued it. It does not prove the number attached to a call is true. The only number a server can trust is the one it wrote itself, in its own ledger, after its own check. Everything else is something the caller said.&lt;/p&gt;

&lt;h2&gt;
  
  
  FAQ
&lt;/h2&gt;

&lt;p&gt;&lt;strong&gt;What is the difference between the unmetered and metered agent in this build?&lt;/strong&gt;&lt;br&gt;
The unmetered agent's token carries no owner or spend-limit claim, so the server treats every call as unattributed and uncapped. The metered agent's token carries both claims, and the server checks them against a running total it keeps for that owner in Convex.&lt;/p&gt;

&lt;p&gt;&lt;strong&gt;Why does the naive agent's token still pass signature verification if it is lying about its spend?&lt;/strong&gt;&lt;br&gt;
A JWT's signature only proves Kinde issued the token and it has not expired. It says nothing about the rest of the request. The naive route reads a claimed spend value from a query parameter the caller controls. Signature verification has no way to catch that, because checking a claimed dollar amount is not what a signature check does.&lt;/p&gt;

&lt;p&gt;&lt;strong&gt;Why store the spend limit as a Kinde Property instead of a custom claim somewhere else?&lt;/strong&gt;&lt;br&gt;
A Kinde Property attaches directly to an M2M application. Token customization can include it in every token that application issues. No separate lookup is needed to find which agent owns which limit. The tradeoff is Kinde's Property types, which store text, not numbers, so the server parses the value before comparing it.&lt;/p&gt;

&lt;p&gt;&lt;strong&gt;What happens if two calls arrive at the same instant?&lt;/strong&gt;&lt;br&gt;
Convex queues mutations and runs them one at a time. Both calls still get a correct answer: whichever one Convex processes first sees the lower ledger total and may be allowed, and the other sees the updated total and may be denied. The total stays correct. Which specific call wins is not guaranteed to match the order the calls arrived in.&lt;/p&gt;

&lt;p&gt;&lt;strong&gt;Does this pattern work with an identity provider other than Kinde, or a backend other than Convex?&lt;/strong&gt;&lt;br&gt;
Yes. Any provider that lets an application attach custom, scoped claims to an M2M token can take &lt;a href="https://kinde.com/?utm_source=devto&amp;amp;utm_medium=content&amp;amp;utm_campaign=shola&amp;amp;campaignid=chatgptapp&amp;amp;network=&amp;amp;adgroup=&amp;amp;keyword=&amp;amp;matchtype=&amp;amp;creative=26&amp;amp;device=&amp;amp;adposition=" rel="noopener noreferrer"&gt;Kinde's&lt;/a&gt; place here. Any backend that runs writes safely under concurrent load, keeps a database, and can verify a JWT can take Convex's place.&lt;/p&gt;

&lt;p&gt;&lt;strong&gt;Why not just rate-limit each agent to a fixed number of calls per minute instead of tracking total spend?&lt;/strong&gt;&lt;br&gt;
A call-count limit says nothing about cost. One agent's calls might cost $0.10 each. Another's might cost $4.00 each. A spend ledger caps what actually matters: money spent, not requests made.&lt;/p&gt;

&lt;h2&gt;
  
  
  Reproduce it
&lt;/h2&gt;

&lt;ol&gt;
&lt;li&gt;Set up two Kinde M2M applications. Give one no Properties. Give the other an &lt;code&gt;agent_owner&lt;/code&gt; and an &lt;code&gt;agent_spend_limit_usd&lt;/code&gt; Property, both turned on under Token customization.&lt;/li&gt;
&lt;li&gt;Set &lt;code&gt;KINDE_ISSUER&lt;/code&gt;, &lt;code&gt;KINDE_M2M_AUDIENCE&lt;/code&gt;, and each application's client ID and secret on the Convex deployment itself with &lt;code&gt;npx convex env set&lt;/code&gt;. Convex's HTTP actions read the deployment's own environment, not &lt;code&gt;.env.local&lt;/code&gt;.&lt;/li&gt;
&lt;li&gt;Run &lt;code&gt;npm run prove:spend-cap&lt;/code&gt; to see the unmetered and metered agents run their sequential calls.&lt;/li&gt;
&lt;li&gt;Run &lt;code&gt;npm run prove:naive-bypass&lt;/code&gt; to see the claimed-spend route accept 20 calls it should have denied after 4.&lt;/li&gt;
&lt;li&gt;Run &lt;code&gt;npm run prove:concurrency&lt;/code&gt; to fire 20 calls at the real, ledger-enforced route at the same instant.&lt;/li&gt;
&lt;/ol&gt;

&lt;p&gt;Full source: &lt;a href="https://github.com/sholajegede/agent-token-ownership-demo" rel="noopener noreferrer"&gt;github.com/sholajegede/agent-token-ownership-demo&lt;/a&gt;&lt;/p&gt;

&lt;ul&gt;
&lt;li&gt;&lt;a href="https://docs.kinde.com/build/tokens/about-access-tokens/" rel="noopener noreferrer"&gt;Kinde: About access tokens&lt;/a&gt;&lt;/li&gt;
&lt;li&gt;&lt;a href="https://docs.kinde.com/machine-to-machine-applications/about-m2m/authenticate-with-m2m/" rel="noopener noreferrer"&gt;Kinde: Authenticate with M2M applications&lt;/a&gt;&lt;/li&gt;
&lt;li&gt;&lt;a href="https://docs.kinde.com/machine-to-machine-applications/m2m-application-setup/add-metadata-to-an-m2m-application-with-properties/" rel="noopener noreferrer"&gt;Kinde: Add metadata to an M2M application with properties&lt;/a&gt;&lt;/li&gt;
&lt;li&gt;&lt;a href="https://docs.convex.dev/functions/http-actions" rel="noopener noreferrer"&gt;Convex: HTTP actions&lt;/a&gt;&lt;/li&gt;
&lt;li&gt;&lt;a href="https://docs.convex.dev/production/environment-variables" rel="noopener noreferrer"&gt;Convex: Environment variables&lt;/a&gt;&lt;/li&gt;
&lt;li&gt;&lt;a href="https://www.helpnetsecurity.com/2026/09/16/google-mandiant-enterprise-ai-security-risks-report/" rel="noopener noreferrer"&gt;One runaway AI agent racked up a $50,000 cloud bill — Help Net Security&lt;/a&gt;&lt;/li&gt;
&lt;/ul&gt;

&lt;p&gt;Every number here came from three real scripts against a real Kinde tenant and a real Convex deployment. Clone the repo and run them against your own tenant. If your own naive route somehow keeps real spend under the limit anyway, or your own concurrency test lets through more calls than the math allows, tell me why in the comments.&lt;/p&gt;

</description>
      <category>ai</category>
      <category>kinde</category>
      <category>agents</category>
      <category>convex</category>
    </item>
    <item>
      <title>How to Stop a Leaked AI Agent Key From Still Working With Kinde Access Tokens</title>
      <dc:creator>Shola Jegede</dc:creator>
      <pubDate>Sat, 19 Sep 2026 02:20:21 +0000</pubDate>
      <link>https://dev.to/sholajegede/how-to-stop-a-leaked-ai-agent-key-from-still-working-with-kinde-access-tokens-2je5</link>
      <guid>https://dev.to/sholajegede/how-to-stop-a-leaked-ai-agent-key-from-still-working-with-kinde-access-tokens-2je5</guid>
      <description>&lt;p&gt;In September 2026, VentureBeat reported that AI agents had used exposed credentials to breach 395 organizations. The report made a simple point: identity systems still treat an agent's credential the way they treat a human's password. Nobody expects a human to type a password every few minutes, so nobody had built agent credentials to expire that fast either. And this was a gap the attackers saw and used. A separate breach at Hugging Face traced back to the same failure: an agent held a credential that outlived the task it was issued for.&lt;/p&gt;

&lt;p&gt;Most AI agents get one machine-to-machine (M2M) access token at start-up and keep it for the life of the process. If that token ends up in a log file, a support ticket, or a copied environment variable, it stays valid for however long its issuer configured it. For most identity providers, that is somewhere between one hour and one day.&lt;/p&gt;

&lt;p&gt;To try to solve this problem, I built two versions of the same agent against &lt;a href="https://kinde.com/?utm_source=devto&amp;amp;utm_medium=content&amp;amp;utm_campaign=shola&amp;amp;campaignid=chatgptapp&amp;amp;network=&amp;amp;adgroup=&amp;amp;keyword=&amp;amp;matchtype=&amp;amp;creative=25&amp;amp;device=&amp;amp;adposition=" rel="noopener noreferrer"&gt;Kinde&lt;/a&gt;, an identity provider that issues M2M access tokens and verifies every call against them. Kinde also lets you set, per application, how long an access token lives before it expires. One agent in my build takes Kinde's default and never checks it again. The other agent treats its token as something with a short shelf life, and rebuilds it before that shelf life runs out.&lt;/p&gt;

&lt;p&gt;Then I'm going to try to steal both tokens and reuse them again.&lt;/p&gt;

&lt;h2&gt;
  
  
  Why the leaked token still works
&lt;/h2&gt;

&lt;p&gt;An M2M credential in Kinde has two parts: a client ID and a client secret, and an access token that Kinde issues when an app presents that ID and secret. The access token is what the app actually sends on every API call. Kinde signs it, and any server that trusts Kinde can verify that signature without calling Kinde back.&lt;/p&gt;

&lt;p&gt;The client ID and secret rarely change. Most teams set them once, in an environment variable, and leave them alone. The access token is supposed to be different: Kinde gives it a lifetime, after which it stops working no matter who holds it.&lt;/p&gt;

&lt;p&gt;The problem is what an agent's code does with that access token after Kinde hands it over. A static agent fetches one token when it starts, stores it in memory, and reuses it for every call until the process restarts. If that process runs for hours or days, so does the token. If someone copies the token during that time, they hold something that is still good.&lt;/p&gt;

&lt;p&gt;A rotating agent does the opposite. It checks the token's age before every call. And before the token gets close to expiring, the agent throws it away and asks Kinde for a new one. A copy of that token, made at any point in its short life, stops working within minutes.&lt;/p&gt;

&lt;p&gt;Both agents get their tokens the same way: a client-credentials grant. The agent sends its client ID and client secret directly to Kinde's token endpoint. There is no human and no browser involved in this process. Kinde sends back a signed access token. This is the standard OAuth flow for service-to-service calls, and it is the same flow whether the token that comes back lives for two minutes or two days. The grant itself does not decide how long the token is good for. The &lt;a href="https://kinde.com/?utm_source=devto&amp;amp;utm_medium=content&amp;amp;utm_campaign=shola&amp;amp;campaignid=chatgptapp&amp;amp;network=&amp;amp;adgroup=&amp;amp;keyword=&amp;amp;matchtype=&amp;amp;creative=25&amp;amp;device=&amp;amp;adposition=" rel="noopener noreferrer"&gt;application's settings in Kinde&lt;/a&gt; do.&lt;/p&gt;

&lt;h2&gt;
  
  
  The enforcement seam
&lt;/h2&gt;

&lt;p&gt;Both agents in this build call the same API, and that API runs the same check on every request, with no branch for which agent is calling.&lt;br&gt;
&lt;/p&gt;

&lt;pre data-lang="mermaid"&gt;&lt;code&gt;sequenceDiagram
    participant Agent as Agent (static or rotating)
    participant Kinde
    participant API as Records API (Convex)

    Agent-&amp;gt;&amp;gt;Kinde: client_credentials grant
    Kinde--&amp;gt;&amp;gt;Agent: access_token (24h or 120s expiry)
    Agent-&amp;gt;&amp;gt;API: GET /api/records?action=... (Bearer token)
    API-&amp;gt;&amp;gt;Kinde: verify signature via JWKS
    API--&amp;gt;&amp;gt;Agent: 200 + data, or 401 if expired/invalid

    Note over Agent,API: Later, an attacker replays a captured token directly
    Agent-&amp;gt;&amp;gt;API: same token, no agent involved
    API--&amp;gt;&amp;gt;Agent: static: 200 (still valid) / rotating: 401 (expired)&lt;/code&gt;&lt;/pre&gt;



&lt;p&gt;The API is a single Convex function. It reads the bearer token from the request, checks its signature against Kinde's public key set (JWKS), and checks that the token has not expired:&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight typescript"&gt;&lt;code&gt;&lt;span class="kd"&gt;const&lt;/span&gt; &lt;span class="p"&gt;{&lt;/span&gt; &lt;span class="nx"&gt;payload&lt;/span&gt; &lt;span class="p"&gt;}&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="k"&gt;await&lt;/span&gt; &lt;span class="nf"&gt;jwtVerify&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="nx"&gt;token&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="nf"&gt;getJwks&lt;/span&gt;&lt;span class="p"&gt;(),&lt;/span&gt; &lt;span class="p"&gt;{&lt;/span&gt;
  &lt;span class="na"&gt;issuer&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="nf"&gt;requiredEnv&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="dl"&gt;"&lt;/span&gt;&lt;span class="s2"&gt;KINDE_ISSUER&lt;/span&gt;&lt;span class="dl"&gt;"&lt;/span&gt;&lt;span class="p"&gt;),&lt;/span&gt;
  &lt;span class="na"&gt;audience&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="nf"&gt;requiredEnv&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="dl"&gt;"&lt;/span&gt;&lt;span class="s2"&gt;KINDE_M2M_AUDIENCE&lt;/span&gt;&lt;span class="dl"&gt;"&lt;/span&gt;&lt;span class="p"&gt;),&lt;/span&gt;
&lt;span class="p"&gt;});&lt;/span&gt;
&lt;span class="kd"&gt;const&lt;/span&gt; &lt;span class="nx"&gt;mode&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="nf"&gt;modeForClientId&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="nx"&gt;payload&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nx"&gt;azp&lt;/span&gt; &lt;span class="k"&gt;as&lt;/span&gt; &lt;span class="kr"&gt;string&lt;/span&gt; &lt;span class="o"&gt;|&lt;/span&gt; &lt;span class="kc"&gt;undefined&lt;/span&gt;&lt;span class="p"&gt;);&lt;/span&gt;
&lt;span class="k"&gt;if &lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="o"&gt;!&lt;/span&gt;&lt;span class="nx"&gt;mode&lt;/span&gt;&lt;span class="p"&gt;)&lt;/span&gt; &lt;span class="k"&gt;return&lt;/span&gt; &lt;span class="k"&gt;await&lt;/span&gt; &lt;span class="nf"&gt;deny&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="mi"&gt;403&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="dl"&gt;"&lt;/span&gt;&lt;span class="s2"&gt;token not issued to a known agent client&lt;/span&gt;&lt;span class="dl"&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 &lt;code&gt;jwtVerify&lt;/code&gt; call, from the &lt;code&gt;jose&lt;/code&gt; library, throws the moment a token's signature is wrong or its &lt;code&gt;exp&lt;/code&gt; claim has passed. That single check is what turns a Kinde-configured expiry into an actual rejection. Nothing about this function knows or cares which agent sent the request; it treats every token the same way, and the token's own expiry decides the outcome.&lt;/p&gt;

&lt;p&gt;The only difference between the two agents lives in &lt;a href="https://kinde.com/?utm_source=devto&amp;amp;utm_medium=content&amp;amp;utm_campaign=shola&amp;amp;campaignid=chatgptapp&amp;amp;network=&amp;amp;adgroup=&amp;amp;keyword=&amp;amp;matchtype=&amp;amp;creative=25&amp;amp;device=&amp;amp;adposition=" rel="noopener noreferrer"&gt;Kinde's dashboard&lt;/a&gt;, not in this code. The static agent's Kinde application keeps the default access token expiry: 86,400 seconds, one full day. The rotating agent's application is set to 120 seconds. Change that one number, and the identical verification code above starts rejecting tokens on a different schedule, with no redeploy of its own.&lt;/p&gt;

&lt;h2&gt;
  
  
  Two credential managers, one verification function
&lt;/h2&gt;

&lt;p&gt;The static agent's credential manager fetches a token once and holds it:&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight typescript"&gt;&lt;code&gt;&lt;span class="kd"&gt;let&lt;/span&gt; &lt;span class="nx"&gt;staticToken&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="nx"&gt;CachedToken&lt;/span&gt; &lt;span class="o"&gt;|&lt;/span&gt; &lt;span class="kc"&gt;null&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="kc"&gt;null&lt;/span&gt;&lt;span class="p"&gt;;&lt;/span&gt;
&lt;span class="k"&gt;export&lt;/span&gt; &lt;span class="k"&gt;async&lt;/span&gt; &lt;span class="kd"&gt;function&lt;/span&gt; &lt;span class="nf"&gt;getStaticCredential&lt;/span&gt;&lt;span class="p"&gt;():&lt;/span&gt; &lt;span class="nb"&gt;Promise&lt;/span&gt;&lt;span class="o"&gt;&amp;lt;&lt;/span&gt;&lt;span class="nx"&gt;CachedToken&lt;/span&gt;&lt;span class="o"&gt;&amp;gt;&lt;/span&gt; &lt;span class="p"&gt;{&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;staticToken&lt;/span&gt;&lt;span class="p"&gt;)&lt;/span&gt; &lt;span class="k"&gt;return&lt;/span&gt; &lt;span class="nx"&gt;staticToken&lt;/span&gt;&lt;span class="p"&gt;;&lt;/span&gt;
  &lt;span class="nx"&gt;staticToken&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="k"&gt;await&lt;/span&gt; &lt;span class="nf"&gt;fetchM2MToken&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;
    &lt;span class="nf"&gt;env&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="dl"&gt;"&lt;/span&gt;&lt;span class="s2"&gt;STATIC_AGENT_CLIENT_ID&lt;/span&gt;&lt;span class="dl"&gt;"&lt;/span&gt;&lt;span class="p"&gt;),&lt;/span&gt;
    &lt;span class="nf"&gt;env&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="dl"&gt;"&lt;/span&gt;&lt;span class="s2"&gt;STATIC_AGENT_CLIENT_SECRET&lt;/span&gt;&lt;span class="dl"&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="nx"&gt;staticToken&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 rotating agent's credential manager checks the token's age before every use, with a 15-second safety margin, and fetches a new one if the old one is close to expiring:&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight typescript"&gt;&lt;code&gt;&lt;span class="kd"&gt;const&lt;/span&gt; &lt;span class="nx"&gt;ROTATION_SAFETY_MARGIN_MS&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="mi"&gt;15&lt;/span&gt;&lt;span class="nx"&gt;_000&lt;/span&gt;&lt;span class="p"&gt;;&lt;/span&gt;
&lt;span class="kd"&gt;let&lt;/span&gt; &lt;span class="nx"&gt;rotatingToken&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="nx"&gt;CachedToken&lt;/span&gt; &lt;span class="o"&gt;|&lt;/span&gt; &lt;span class="kc"&gt;null&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="kc"&gt;null&lt;/span&gt;&lt;span class="p"&gt;;&lt;/span&gt;
&lt;span class="k"&gt;export&lt;/span&gt; &lt;span class="k"&gt;async&lt;/span&gt; &lt;span class="kd"&gt;function&lt;/span&gt; &lt;span class="nf"&gt;getRotatingCredential&lt;/span&gt;&lt;span class="p"&gt;():&lt;/span&gt; &lt;span class="nb"&gt;Promise&lt;/span&gt;&lt;span class="o"&gt;&amp;lt;&lt;/span&gt;&lt;span class="nx"&gt;CachedToken&lt;/span&gt;&lt;span class="o"&gt;&amp;gt;&lt;/span&gt; &lt;span class="p"&gt;{&lt;/span&gt;
  &lt;span class="kd"&gt;const&lt;/span&gt; &lt;span class="nx"&gt;now&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="nb"&gt;Date&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nf"&gt;now&lt;/span&gt;&lt;span class="p"&gt;();&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;rotatingToken&lt;/span&gt; &lt;span class="o"&gt;&amp;amp;&amp;amp;&lt;/span&gt; &lt;span class="nx"&gt;now&lt;/span&gt; &lt;span class="o"&gt;&amp;lt;&lt;/span&gt; &lt;span class="nx"&gt;rotatingToken&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nx"&gt;expiresAt&lt;/span&gt; &lt;span class="o"&gt;-&lt;/span&gt; &lt;span class="nx"&gt;ROTATION_SAFETY_MARGIN_MS&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="nx"&gt;rotatingToken&lt;/span&gt;&lt;span class="p"&gt;;&lt;/span&gt;
  &lt;span class="p"&gt;}&lt;/span&gt;
  &lt;span class="nx"&gt;rotatingToken&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="k"&gt;await&lt;/span&gt; &lt;span class="nf"&gt;fetchM2MToken&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;
    &lt;span class="nf"&gt;env&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="dl"&gt;"&lt;/span&gt;&lt;span class="s2"&gt;ROTATING_AGENT_CLIENT_ID&lt;/span&gt;&lt;span class="dl"&gt;"&lt;/span&gt;&lt;span class="p"&gt;),&lt;/span&gt;
    &lt;span class="nf"&gt;env&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="dl"&gt;"&lt;/span&gt;&lt;span class="s2"&gt;ROTATING_AGENT_CLIENT_SECRET&lt;/span&gt;&lt;span class="dl"&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="nx"&gt;rotatingToken&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;Neither function knows about the other, or what the API on the other end will do with the token. The only thing that changes the outcome is how long Kinde told each token to live, and whether the calling code respects that.&lt;/p&gt;

&lt;p&gt;A third file in the build, a closed action registry, lists the only three calls either agent may make: &lt;code&gt;list_records&lt;/code&gt;, &lt;code&gt;read_record&lt;/code&gt;, and &lt;code&gt;export_records&lt;/code&gt;. The API rejects any other action before it even looks at the token. This keeps the demo's scope narrow: the test here is about token lifetime, not about which actions an agent should be allowed to take.&lt;/p&gt;

&lt;h2&gt;
  
  
  The bug I found during hardening
&lt;/h2&gt;

&lt;p&gt;During a review of the API's verification code, a bug turned up. When a request carried no token, or a token from an application the API did not recognize, the logging code defaulted that request's log entry to &lt;code&gt;mode: "static"&lt;/code&gt;. An anonymous or malformed request would then show up in the dashboard as if the static agent had made it.&lt;/p&gt;

&lt;p&gt;This did not affect the proof numbers below, since every call in this build came from one of the two known agents. It got fixed before the proof ran, though: unattributed requests now log as &lt;code&gt;mode: "unknown"&lt;/code&gt;, a separate value from &lt;code&gt;"static"&lt;/code&gt; and &lt;code&gt;"rotating"&lt;/code&gt;.&lt;/p&gt;

&lt;h2&gt;
  
  
  Live proof
&lt;/h2&gt;

&lt;p&gt;With both Kinde applications configured (24 hours for static, 120 seconds for rotating), the proof works in four steps. First, each agent asks Kinde for a token. Second, both tokens are captured at the same instant, the way a log scraper or a support-ticket screenshot would capture them. Third, the script waits past the rotating agent's 120-second window. Fourth, both captured tokens get replayed, unmodified, straight against the API, without going through either agent's own code.&lt;/p&gt;

&lt;p&gt;Static Agent's app, at the default 86,400-second expiry:&lt;/p&gt;

&lt;p&gt;&lt;a href="https://media2.dev.to/dynamic/image/width=800%2Cheight=%2Cfit=scale-down%2Cgravity=auto%2Cformat=auto/https%3A%2F%2Fdev-to-uploads.s3.us-east-2.amazonaws.com%2Fuploads%2Farticles%2Fliqo3ragih7egiueh4p9.png" class="article-body-image-wrapper"&gt;&lt;img src="https://media2.dev.to/dynamic/image/width=800%2Cheight=%2Cfit=scale-down%2Cgravity=auto%2Cformat=auto/https%3A%2F%2Fdev-to-uploads.s3.us-east-2.amazonaws.com%2Fuploads%2Farticles%2Fliqo3ragih7egiueh4p9.png" alt="Static agent Kinde token expiry" width="800" height="278"&gt;&lt;/a&gt;&lt;/p&gt;

&lt;p&gt;Rotating Agent's app, set to 120 seconds:&lt;/p&gt;

&lt;p&gt;&lt;a href="https://media2.dev.to/dynamic/image/width=800%2Cheight=%2Cfit=scale-down%2Cgravity=auto%2Cformat=auto/https%3A%2F%2Fdev-to-uploads.s3.us-east-2.amazonaws.com%2Fuploads%2Farticles%2Fn1954nupg61bqy04xisg.png" class="article-body-image-wrapper"&gt;&lt;img src="https://media2.dev.to/dynamic/image/width=800%2Cheight=%2Cfit=scale-down%2Cgravity=auto%2Cformat=auto/https%3A%2F%2Fdev-to-uploads.s3.us-east-2.amazonaws.com%2Fuploads%2Farticles%2Fn1954nupg61bqy04xisg.png" alt="Rotating agent Kinde token expiry" width="800" height="278"&gt;&lt;/a&gt;&lt;/p&gt;

&lt;p&gt;The live dashboard, built with Convex, shows both agents' history in real time as each call happens:&lt;/p&gt;

&lt;p&gt;&lt;a href="https://media2.dev.to/dynamic/image/width=800%2Cheight=%2Cfit=scale-down%2Cgravity=auto%2Cformat=auto/https%3A%2F%2Fdev-to-uploads.s3.us-east-2.amazonaws.com%2Fuploads%2Farticles%2Fr5i8ldu049s3t65w4mvr.png" class="article-body-image-wrapper"&gt;&lt;img src="https://media2.dev.to/dynamic/image/width=800%2Cheight=%2Cfit=scale-down%2Cgravity=auto%2Cformat=auto/https%3A%2F%2Fdev-to-uploads.s3.us-east-2.amazonaws.com%2Fuploads%2Farticles%2Fr5i8ldu049s3t65w4mvr.png" alt="Live dashboard showing both agents and the leak replay proof" width="800" height="715"&gt;&lt;/a&gt;&lt;/p&gt;

&lt;p&gt;The replay produced these results:&lt;/p&gt;

&lt;div class="table-wrapper-paragraph"&gt;&lt;table&gt;
&lt;thead&gt;
&lt;tr&gt;
&lt;th&gt;Agent&lt;/th&gt;
&lt;th&gt;Token expiry&lt;/th&gt;
&lt;th&gt;Replay result&lt;/th&gt;
&lt;th&gt;Status&lt;/th&gt;
&lt;th&gt;Latency&lt;/th&gt;
&lt;/tr&gt;
&lt;/thead&gt;
&lt;tbody&gt;
&lt;tr&gt;
&lt;td&gt;Static&lt;/td&gt;
&lt;td&gt;24h (default)&lt;/td&gt;
&lt;td&gt;still works&lt;/td&gt;
&lt;td&gt;200&lt;/td&gt;
&lt;td&gt;1353ms&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;Rotating&lt;/td&gt;
&lt;td&gt;120s&lt;/td&gt;
&lt;td&gt;dead&lt;/td&gt;
&lt;td&gt;401&lt;/td&gt;
&lt;td&gt;938ms&lt;/td&gt;
&lt;/tr&gt;
&lt;/tbody&gt;
&lt;/table&gt;&lt;/div&gt;

&lt;h2&gt;
  
  
  A stolen token's usable window: a day vs two minutes
&lt;/h2&gt;

&lt;p&gt;The static agent's leaked token authenticated successfully more than two minutes after it was captured, using the exact same replay the rotating agent's token failed. It will keep authenticating for the rest of its 24-hour life, because nothing in this build, or in most production setups, checks whether a token has been copied. Kinde's own JWT verification only checks whether a token is signed correctly and has not expired. It has no way to know the token in front of it is a copy.&lt;/p&gt;

&lt;p&gt;The rotating agent's leaked token failed after 120 seconds, the same limit set on its Kinde application. Whoever captured it had, at most, two minutes to use it before Kinde's own signature check started returning 401 on every attempt.&lt;/p&gt;

&lt;p&gt;The gap between those two numbers, a day against two minutes, is the entire value of short-lived credentials. Nothing about the attack changes. Nothing about the API's verification code changes. Only the answer to "how long is a copy of this token worth anything" changes, and that answer comes from one number set on one screen in Kinde's dashboard.&lt;/p&gt;

&lt;h2&gt;
  
  
  What I think about all of this
&lt;/h2&gt;

&lt;p&gt;Kinde already gives every M2M application a token with an expiry, and most teams never touch that setting. I don't think that is a Kinde problem. Most agent code never checks the expiry either; it just holds whatever Kinde handed back at start-up. Short-lived credentials are not a feature you have to add. The primitive already exists. What is missing is code that treats the expiry as real, instead of code that mints a token once and assumes it will always still be good.&lt;/p&gt;

&lt;p&gt;The fix in this build took two changes: setting one number in Kinde's dashboard, and writing a credential manager that checks the clock before it checks a cache. Neither change touched the API's verification logic at all.&lt;/p&gt;

&lt;p&gt;I would argue the harder habit to build is not the code. It is remembering that a credential an agent holds in memory is a credential that can leave that memory. Developers on Hacker News asked whether they would trust an agent with an API key at all. Most kept landing on the same answer: give the agent a temporary, narrowly scoped credential, issued through something that logs when it was requested. Not a long-lived key sitting in an environment variable. A short access token from an identity provider that already tracks every issuance is a plain way to get that, without building a separate credential broker.&lt;/p&gt;

&lt;h2&gt;
  
  
  Limitations
&lt;/h2&gt;

&lt;p&gt;&lt;a href="https://kinde.com/?utm_source=devto&amp;amp;utm_medium=content&amp;amp;utm_campaign=shola&amp;amp;campaignid=chatgptapp&amp;amp;network=&amp;amp;adgroup=&amp;amp;keyword=&amp;amp;matchtype=&amp;amp;creative=25&amp;amp;device=&amp;amp;adposition=" rel="noopener noreferrer"&gt;Kinde&lt;/a&gt; documents rotating an M2M application's client secret as a manual action, triggered from the dashboard or the Management API, not something Kinde does on a schedule. This build rotates the access token, which Kinde issues fresh on every client-credentials request. That is the layer that matters for a leaked-token scenario, but it is a different mechanism from secret rotation, and the two should not be confused for each other.&lt;/p&gt;

&lt;p&gt;120 seconds is a number chosen so this proof runs in a couple of minutes, not a production recommendation. A production window more commonly sits between 5 and 15 minutes, traded off against how often a workload can tolerate a refresh call.&lt;/p&gt;

&lt;p&gt;The API's JWT verification returns a generic 401 for every failure case. It does not tell a caller whether their token expired, or whether it came from an application the API does not recognize. That is fine for this demo. A real audit trail would need to tell those cases apart.&lt;/p&gt;

&lt;h2&gt;
  
  
  Code and sources
&lt;/h2&gt;

&lt;p&gt;Full source: &lt;a href="https://github.com/sholajegede/rotating-credentials-demo" rel="noopener noreferrer"&gt;github.com/sholajegede/rotating-credentials-demo&lt;/a&gt;&lt;/p&gt;

&lt;ul&gt;
&lt;li&gt;&lt;a href="https://docs.kinde.com/build/tokens/about-access-tokens/" rel="noopener noreferrer"&gt;Kinde: About access tokens&lt;/a&gt;&lt;/li&gt;
&lt;li&gt;&lt;a href="https://docs.kinde.com/machine-to-machine-applications/about-m2m/authenticate-with-m2m/" rel="noopener noreferrer"&gt;Kinde: Authenticate with M2M applications&lt;/a&gt;&lt;/li&gt;
&lt;li&gt;&lt;a href="https://docs.convex.dev/functions/http-actions" rel="noopener noreferrer"&gt;Convex: HTTP actions&lt;/a&gt;&lt;/li&gt;
&lt;li&gt;&lt;a href="https://docs.claude.com/en/docs/agents-and-tools/tool-use/overview" rel="noopener noreferrer"&gt;Anthropic: Tool use documentation&lt;/a&gt;&lt;/li&gt;
&lt;li&gt;&lt;a href="https://venturebeat.com/security/ai-agents-breached-395-organizations-using-credentials-your-iam-policy-still-treats-as-human" rel="noopener noreferrer"&gt;AI agents breached 395 organizations using credentials your IAM policy still treats as human — VentureBeat&lt;/a&gt;&lt;/li&gt;
&lt;li&gt;&lt;a href="https://www.akeyless.io/blog/hugging-face-breach-ai-agent-identity-security/" rel="noopener noreferrer"&gt;Hugging Face Breach: An AI Agent Identity Security Lesson — Akeyless&lt;/a&gt;&lt;/li&gt;
&lt;li&gt;&lt;a href="https://news.ycombinator.com/item?id=47736831" rel="noopener noreferrer"&gt;Ask HN: Do you trust AI agents with API keys / private keys?&lt;/a&gt;&lt;/li&gt;
&lt;/ul&gt;

&lt;p&gt;Every number in this article came from a live run against a real Kinde tenant and a real Convex deployment. Clone the repo, run the same proof against your own tenant, and drop your numbers in the comments. If your static agent's leaked token survives longer than 24 hours, or your rotating agent's dies in under 120 seconds, tell me why.&lt;/p&gt;

</description>
      <category>ai</category>
      <category>agents</category>
      <category>kinde</category>
      <category>programming</category>
    </item>
    <item>
      <title>How to Auto-Revoke a Claude Agent's Access When a User Is Offboarded With Kinde Webhooks</title>
      <dc:creator>Shola Jegede</dc:creator>
      <pubDate>Thu, 17 Sep 2026 01:36:32 +0000</pubDate>
      <link>https://dev.to/sholajegede/how-to-auto-revoke-a-claude-agents-access-when-a-user-is-offboarded-with-kinde-webhooks-1ccf</link>
      <guid>https://dev.to/sholajegede/how-to-auto-revoke-a-claude-agents-access-when-a-user-is-offboarded-with-kinde-webhooks-1ccf</guid>
      <description>&lt;p&gt;Imagine this scenario, someone on your team gets offboarded while their AI agent is still mid-task. Nobody remembers the agent is even running. It's just doing what it was told, on behalf of someone who, as of a minute ago, doesn't work at your company or on your team anymore.&lt;/p&gt;

&lt;p&gt;Does it stop?&lt;/p&gt;

&lt;p&gt;Well, I built a small app to test that scenario, using &lt;a href="https://kinde.com/?utm_source=devto&amp;amp;utm_medium=content&amp;amp;utm_campaign=shola&amp;amp;campaignid=chatgptapp&amp;amp;network=&amp;amp;adgroup=&amp;amp;keyword=&amp;amp;matchtype=&amp;amp;creative=24&amp;amp;device=&amp;amp;adposition=" rel="noopener noreferrer"&gt;Kinde&lt;/a&gt; to handle sign-in and to hold the record of who's still active. I signed a real user in, handed their agent a task, and while the agent was still working through it, suspended that same user, in Kinde's dashboard, mid-run, to see what the agent would do next.&lt;/p&gt;

&lt;p&gt;Unsurprisingly, the agent kept working on the task.&lt;/p&gt;

&lt;p&gt;You see, suspending or deleting a person changes how Kinde itself sees that user, but it doesn't touch the access token their agent is already holding, because nothing about a suspension reaches back into a token that was already signed and handed out before it happened. The token still verifies exactly as it did before the suspension, so the agent has no way to know anything changed.&lt;/p&gt;

&lt;p&gt;Everything I am going to talk about in this article is about closing that gap, and about what I actually found while doing it: webhook deliveries measured live, a production bug that could have left an offboarded user's record looking active forever, and a hard number for how long an offboarded person's agent keeps acting before anything catches it.&lt;/p&gt;

&lt;h2&gt;
  
  
  Why a suspended user's token still works
&lt;/h2&gt;

&lt;p&gt;Let's start with what an access token actually is, because the whole gap follows from it. An OAuth access token isn't a receipt you hand back to check against a ledger. It's a signed claim, a small JSON payload with a cryptographic signature attached, and whatever's checking it just verifies that signature against a public key rather than calling home to ask if the token's still good. That's the entire appeal of the design: an API can confirm a token is genuine without a database round trip on every request.&lt;/p&gt;

&lt;p&gt;Which means suspending a user in Kinde only changes a row in Kinde's own database. It doesn't reach the token at all, because there's nothing there for it to reach: the token was already handed out, already signed, already valid until whatever expiry it was minted with. Revoking it properly would mean tracking every issued token in a lookup table somewhere, which throws away the entire point of signing one in the first place, or it would mean just waiting for the thing to expire on its own.&lt;/p&gt;

&lt;p&gt;I suspended a signed-in test user mid-session, and the app's own check kept reporting that user's access token as valid, seconds after Kinde had already suspended them.&lt;/p&gt;

&lt;p&gt;So that gap isn't a bug in Kinde, and it isn't a bug in OAuth either. It's just what a stateless credential is, by design, and the real question is what you build on top of it.&lt;/p&gt;

&lt;h2&gt;
  
  
  The shape of the fix
&lt;/h2&gt;

&lt;p&gt;Two pieces close the gap. A webhook tells the app when Kinde's view of a user changes, and a check runs before every single agent action, reading the app's own record of that user instead of trusting whatever was true when the session started.&lt;/p&gt;

&lt;p&gt;&lt;a href="https://media2.dev.to/dynamic/image/width=800%2Cheight=%2Cfit=scale-down%2Cgravity=auto%2Cformat=auto/https%3A%2F%2Fdev-to-uploads.s3.us-east-2.amazonaws.com%2Fuploads%2Farticles%2Fyfgm7hwfnspmmqcvfozx.png" class="article-body-image-wrapper"&gt;&lt;img src="https://media2.dev.to/dynamic/image/width=800%2Cheight=%2Cfit=scale-down%2Cgravity=auto%2Cformat=auto/https%3A%2F%2Fdev-to-uploads.s3.us-east-2.amazonaws.com%2Fuploads%2Farticles%2Fyfgm7hwfnspmmqcvfozx.png" alt="A flowchart showing how Kinde user suspensions propagate through signed webhooks and reconciliation into Convex, with an enforcement seam controlling Claude agent tool calls." width="800" height="518"&gt;&lt;/a&gt;&lt;/p&gt;

&lt;p&gt;The agent itself is a small Claude Messages API loop, working through a closed set of three tools against a demo set of internal resources: &lt;code&gt;list_resources&lt;/code&gt;, &lt;code&gt;read_resource&lt;/code&gt;, &lt;code&gt;write_resource&lt;/code&gt;. None of what follows is specific to what the agent does. It's specific to the one place every tool call has to pass through before it's allowed to run at all.&lt;/p&gt;

&lt;h2&gt;
  
  
  Building the seam
&lt;/h2&gt;

&lt;p&gt;First, we start with the registry that defines those three actions, because it's closed by construction rather than by convention. An action that isn't in this table doesn't half-exist somewhere in the code, waiting to be called by accident. It just doesn't exist:&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight typescript"&gt;&lt;code&gt;&lt;span class="k"&gt;export&lt;/span&gt; &lt;span class="kd"&gt;const&lt;/span&gt; &lt;span class="nx"&gt;ACTION_REGISTRY&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="nb"&gt;Record&lt;/span&gt;&lt;span class="o"&gt;&amp;lt;&lt;/span&gt;&lt;span class="nx"&gt;ActionName&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="nx"&gt;ActionDefinition&lt;/span&gt;&lt;span class="o"&gt;&amp;gt;&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="p"&gt;{&lt;/span&gt;
  &lt;span class="na"&gt;list_resources&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="p"&gt;{&lt;/span&gt; &lt;span class="na"&gt;name&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="dl"&gt;"&lt;/span&gt;&lt;span class="s2"&gt;list_resources&lt;/span&gt;&lt;span class="dl"&gt;"&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="na"&gt;destructive&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="kc"&gt;false&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="na"&gt;params&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="na"&gt;read_resource&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="p"&gt;{&lt;/span&gt;
    &lt;span class="na"&gt;name&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="dl"&gt;"&lt;/span&gt;&lt;span class="s2"&gt;read_resource&lt;/span&gt;&lt;span class="dl"&gt;"&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt;
    &lt;span class="na"&gt;destructive&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="kc"&gt;false&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt;
    &lt;span class="na"&gt;params&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="p"&gt;{&lt;/span&gt; &lt;span class="na"&gt;resourceId&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="p"&gt;{&lt;/span&gt; &lt;span class="na"&gt;type&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="dl"&gt;"&lt;/span&gt;&lt;span class="s2"&gt;string&lt;/span&gt;&lt;span class="dl"&gt;"&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="na"&gt;required&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="kc"&gt;true&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="na"&gt;write_resource&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="p"&gt;{&lt;/span&gt;
    &lt;span class="na"&gt;name&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="dl"&gt;"&lt;/span&gt;&lt;span class="s2"&gt;write_resource&lt;/span&gt;&lt;span class="dl"&gt;"&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt;
    &lt;span class="na"&gt;destructive&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="kc"&gt;true&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt;
    &lt;span class="na"&gt;params&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="p"&gt;{&lt;/span&gt;
      &lt;span class="na"&gt;resourceId&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="p"&gt;{&lt;/span&gt; &lt;span class="na"&gt;type&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="dl"&gt;"&lt;/span&gt;&lt;span class="s2"&gt;string&lt;/span&gt;&lt;span class="dl"&gt;"&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="na"&gt;required&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="kc"&gt;true&lt;/span&gt; &lt;span class="p"&gt;},&lt;/span&gt;
      &lt;span class="na"&gt;title&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="p"&gt;{&lt;/span&gt; &lt;span class="na"&gt;type&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="dl"&gt;"&lt;/span&gt;&lt;span class="s2"&gt;string&lt;/span&gt;&lt;span class="dl"&gt;"&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="na"&gt;required&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="kc"&gt;false&lt;/span&gt; &lt;span class="p"&gt;},&lt;/span&gt;
      &lt;span class="na"&gt;body&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="p"&gt;{&lt;/span&gt; &lt;span class="na"&gt;type&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="dl"&gt;"&lt;/span&gt;&lt;span class="s2"&gt;string&lt;/span&gt;&lt;span class="dl"&gt;"&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="na"&gt;required&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="kc"&gt;false&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;Both the tool schema handed to Claude and the enforcement check are built from this same table, so the two can never quietly drift apart from each other the way a schema and a permissions list usually do once someone forgets to update one of them.&lt;/p&gt;

&lt;p&gt;Every tool call the model makes passes through a single function, &lt;code&gt;enforceToolCall&lt;/code&gt;, which looks up the acting user's current status and hands it to a small, pure decision function underneath it:&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight typescript"&gt;&lt;code&gt;&lt;span class="k"&gt;export&lt;/span&gt; &lt;span class="kd"&gt;function&lt;/span&gt; &lt;span class="nf"&gt;decideAccess&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="nx"&gt;input&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="p"&gt;{&lt;/span&gt;
  &lt;span class="nl"&gt;mode&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="nx"&gt;EnforcementMode&lt;/span&gt;&lt;span class="p"&gt;;&lt;/span&gt;
  &lt;span class="nl"&gt;userStatus&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="nx"&gt;UserStatus&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="nl"&gt;decision&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="nx"&gt;SeamDecision&lt;/span&gt;&lt;span class="p"&gt;;&lt;/span&gt; &lt;span class="nl"&gt;reason&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="nx"&gt;SeamReason&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="p"&gt;(&lt;/span&gt;&lt;span class="nx"&gt;input&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nx"&gt;mode&lt;/span&gt; &lt;span class="o"&gt;===&lt;/span&gt; &lt;span class="dl"&gt;"&lt;/span&gt;&lt;span class="s2"&gt;naive&lt;/span&gt;&lt;span class="dl"&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="p"&gt;{&lt;/span&gt; &lt;span class="na"&gt;decision&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="dl"&gt;"&lt;/span&gt;&lt;span class="s2"&gt;allow&lt;/span&gt;&lt;span class="dl"&gt;"&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="na"&gt;reason&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="dl"&gt;"&lt;/span&gt;&lt;span class="s2"&gt;naive_mode_no_check&lt;/span&gt;&lt;span class="dl"&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;if &lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="nx"&gt;input&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nx"&gt;userStatus&lt;/span&gt; &lt;span class="o"&gt;===&lt;/span&gt; &lt;span class="dl"&gt;"&lt;/span&gt;&lt;span class="s2"&gt;active&lt;/span&gt;&lt;span class="dl"&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="p"&gt;{&lt;/span&gt; &lt;span class="na"&gt;decision&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="dl"&gt;"&lt;/span&gt;&lt;span class="s2"&gt;allow&lt;/span&gt;&lt;span class="dl"&gt;"&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="na"&gt;reason&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="dl"&gt;"&lt;/span&gt;&lt;span class="s2"&gt;user_active&lt;/span&gt;&lt;span class="dl"&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;if &lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="nx"&gt;input&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nx"&gt;userStatus&lt;/span&gt; &lt;span class="o"&gt;===&lt;/span&gt; &lt;span class="dl"&gt;"&lt;/span&gt;&lt;span class="s2"&gt;offboarded&lt;/span&gt;&lt;span class="dl"&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="p"&gt;{&lt;/span&gt; &lt;span class="na"&gt;decision&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="dl"&gt;"&lt;/span&gt;&lt;span class="s2"&gt;refuse&lt;/span&gt;&lt;span class="dl"&gt;"&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="na"&gt;reason&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="dl"&gt;"&lt;/span&gt;&lt;span class="s2"&gt;user_offboarded&lt;/span&gt;&lt;span class="dl"&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="p"&gt;{&lt;/span&gt; &lt;span class="na"&gt;decision&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="dl"&gt;"&lt;/span&gt;&lt;span class="s2"&gt;refuse&lt;/span&gt;&lt;span class="dl"&gt;"&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="na"&gt;reason&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="dl"&gt;"&lt;/span&gt;&lt;span class="s2"&gt;user_unknown&lt;/span&gt;&lt;span class="dl"&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;Naive mode allows every call without ever looking at that status, which is the vulnerability this whole piece is about, reproduced on purpose so both modes can run side by side against the exact same code and prove the point cleanly. Enforced mode is stricter in a way that matters: it allows exactly one case, a confirmed active user, and refuses everything else, including a status the seam couldn't even resolve because a read to Convex failed. An unknown status doesn't get the benefit of the doubt.&lt;/p&gt;

&lt;p&gt;That status comes from a webhook. &lt;a href="https://kinde.com/?utm_source=devto&amp;amp;utm_medium=content&amp;amp;utm_campaign=shola&amp;amp;campaignid=chatgptapp&amp;amp;network=&amp;amp;adgroup=&amp;amp;keyword=&amp;amp;matchtype=&amp;amp;creative=24&amp;amp;device=&amp;amp;adposition=" rel="noopener noreferrer"&gt;Kinde&lt;/a&gt; sends a signed event on &lt;code&gt;user.updated&lt;/code&gt; and &lt;code&gt;user.deleted&lt;/code&gt;, the receiver verifies the signature, and then it does one more thing that has nothing to do with the signature at all:&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight typescript"&gt;&lt;code&gt;&lt;span class="k"&gt;export&lt;/span&gt; &lt;span class="kd"&gt;function&lt;/span&gt; &lt;span class="nf"&gt;isFreshWebhookEvent&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;
  &lt;span class="nx"&gt;event&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="nx"&gt;WebhookEvent&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt;
  &lt;span class="nx"&gt;now&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="kr"&gt;number&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="nb"&gt;Date&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nf"&gt;now&lt;/span&gt;&lt;span class="p"&gt;(),&lt;/span&gt;
&lt;span class="p"&gt;):&lt;/span&gt; &lt;span class="nx"&gt;boolean&lt;/span&gt; &lt;span class="p"&gt;{&lt;/span&gt;
  &lt;span class="kd"&gt;const&lt;/span&gt; &lt;span class="nx"&gt;eventTime&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="nb"&gt;Date&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nf"&gt;parse&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="nx"&gt;event&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nx"&gt;timestamp&lt;/span&gt;&lt;span class="p"&gt;);&lt;/span&gt;
  &lt;span class="k"&gt;if &lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="nb"&gt;Number&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nf"&gt;isNaN&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="nx"&gt;eventTime&lt;/span&gt;&lt;span class="p"&gt;))&lt;/span&gt; &lt;span class="k"&gt;return&lt;/span&gt; &lt;span class="kc"&gt;false&lt;/span&gt;&lt;span class="p"&gt;;&lt;/span&gt;
  &lt;span class="k"&gt;return&lt;/span&gt; &lt;span class="nb"&gt;Math&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nf"&gt;abs&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="nx"&gt;now&lt;/span&gt; &lt;span class="o"&gt;-&lt;/span&gt; &lt;span class="nx"&gt;eventTime&lt;/span&gt;&lt;span class="p"&gt;)&lt;/span&gt; &lt;span class="o"&gt;&amp;lt;=&lt;/span&gt; &lt;span class="nx"&gt;MAX_CLOCK_SKEW_MS&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 valid signature only proves Kinde signed this payload at some point. It says nothing about when that was, so without this check, a captured event replayed months later would sail straight past signature verification, since the signature itself never expires, and past deduplication too, because dedup only catches an event id it's already seen before. &lt;code&gt;MAX_CLOCK_SKEW_MS&lt;/code&gt; is five minutes. Anything older than that gets rejected the same way a forged signature would.&lt;/p&gt;

&lt;p&gt;&lt;a href="https://media2.dev.to/dynamic/image/width=800%2Cheight=%2Cfit=scale-down%2Cgravity=auto%2Cformat=auto/https%3A%2F%2Fdev-to-uploads.s3.us-east-2.amazonaws.com%2Fuploads%2Farticles%2Ft1jum6ormuldmfnsz8q5.png" class="article-body-image-wrapper"&gt;&lt;img src="https://media2.dev.to/dynamic/image/width=800%2Cheight=%2Cfit=scale-down%2Cgravity=auto%2Cformat=auto/https%3A%2F%2Fdev-to-uploads.s3.us-east-2.amazonaws.com%2Fuploads%2Farticles%2Ft1jum6ormuldmfnsz8q5.png" alt="Kinde's suspend and restore admin action panel" width="799" height="347"&gt;&lt;/a&gt;&lt;/p&gt;

&lt;h2&gt;
  
  
  The bug the build actually found
&lt;/h2&gt;

&lt;p&gt;Hardening this receiver turned up a real ordering bug. The first version recorded the webhook's delivery, for deduplication, before it applied the actual effect of marking the user offboarded. That ordering has a quiet failure mode: if the effect write failed right after the delivery had already been logged, a retried webhook would look like a duplicate of one already handled and get skipped. The user would never actually get offboarded, and nothing about the system would ever try again.&lt;/p&gt;

&lt;p&gt;The fix took one line of reordering, but it only works because of one property underneath it: the effect, &lt;code&gt;markOffboarded&lt;/code&gt;, is idempotent, so running it twice is always safe. That's why it now runs first, unconditionally, ahead of the bookkeeping whose entire job is to stop it from running a third or fourth time. Recording the delivery first and applying the effect second felt like the more natural order to write. It was also the less safe one.&lt;/p&gt;

&lt;h2&gt;
  
  
  Proving it, live
&lt;/h2&gt;

&lt;p&gt;&lt;code&gt;scripts/e2e-narrative.ts&lt;/code&gt; runs one task against one real Kinde test user, twice: once with the seam in naive mode, once enforced, suspending that same user for real, mid-run, both times. Nothing in this script is simulated. It drives a real agent loop, fires a real suspend call at Kinde, and waits on the actual webhook to arrive over a tunnel before it checks what actually happened.&lt;/p&gt;

&lt;div class="table-wrapper-paragraph"&gt;&lt;table&gt;
&lt;thead&gt;
&lt;tr&gt;
&lt;th&gt;&lt;/th&gt;
&lt;th&gt;naive&lt;/th&gt;
&lt;th&gt;enforced&lt;/th&gt;
&lt;/tr&gt;
&lt;/thead&gt;
&lt;tbody&gt;
&lt;tr&gt;
&lt;td&gt;actions allowed after offboarding&lt;/td&gt;
&lt;td&gt;2&lt;/td&gt;
&lt;td&gt;0&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;where the run stopped&lt;/td&gt;
&lt;td&gt;it didn't, ran to completion&lt;/td&gt;
&lt;td&gt;step 2, reason &lt;code&gt;user_offboarded&lt;/code&gt;
&lt;/td&gt;
&lt;/tr&gt;
&lt;/tbody&gt;
&lt;/table&gt;&lt;/div&gt;

&lt;p&gt;The same story shows up in the operator console, so I ran it once more while writing this, offboarding the signed-in user on purpose partway through a task:&lt;/p&gt;

&lt;p&gt;&lt;a href="https://media2.dev.to/dynamic/image/width=800%2Cheight=%2Cfit=scale-down%2Cgravity=auto%2Cformat=auto/https%3A%2F%2Fdev-to-uploads.s3.us-east-2.amazonaws.com%2Fuploads%2Farticles%2Fw9eg7wjseb5g6wpn2h52.png" class="article-body-image-wrapper"&gt;&lt;img src="https://media2.dev.to/dynamic/image/width=800%2Cheight=%2Cfit=scale-down%2Cgravity=auto%2Cformat=auto/https%3A%2F%2Fdev-to-uploads.s3.us-east-2.amazonaws.com%2Fuploads%2Farticles%2Fw9eg7wjseb5g6wpn2h52.png" alt="The console's live timeline: step 1 allowed while active, step 2 refused after offboarding" width="800" height="628"&gt;&lt;/a&gt;&lt;/p&gt;

&lt;p&gt;Step one lands while the user's still active. The offboard request goes out. Step two refuses, &lt;code&gt;user_offboarded&lt;/code&gt;, with a cutoff latency of 2697ms measured from when the offboarding itself landed in the database, not from when the run started. Every one of those decisions lands in an audit log under a shared correlation id, so a run's full timeline can be pulled back up after the fact, not just watched live:&lt;/p&gt;

&lt;p&gt;&lt;a href="https://media2.dev.to/dynamic/image/width=800%2Cheight=%2Cfit=scale-down%2Cgravity=auto%2Cformat=auto/https%3A%2F%2Fdev-to-uploads.s3.us-east-2.amazonaws.com%2Fuploads%2Farticles%2Fe7e5wlkul9jkvtma03is.png" class="article-body-image-wrapper"&gt;&lt;img src="https://media2.dev.to/dynamic/image/width=800%2Cheight=%2Cfit=scale-down%2Cgravity=auto%2Cformat=auto/https%3A%2F%2Fdev-to-uploads.s3.us-east-2.amazonaws.com%2Fuploads%2Farticles%2Fe7e5wlkul9jkvtma03is.png" alt="Convex's auditLog table, showing real seam decisions and webhook deliveries" width="799" height="254"&gt;&lt;/a&gt;&lt;/p&gt;

&lt;h2&gt;
  
  
  The numbers that matter more than the demo
&lt;/h2&gt;

&lt;p&gt;Across every live webhook delivery in this build, latency ran from 652ms to 2058ms, across suspends, restores, deletes, and role changes alike. That's not instant. Most people assume it is.&lt;/p&gt;

&lt;p&gt;The enforcement check itself doesn't belong anywhere in that number. It's one indexed read against Convex, and next to webhook delivery it's close enough to free that it doesn't move the total.&lt;/p&gt;

&lt;p&gt;So the more honest way to describe total revocation speed is this: it's webhook delivery latency, plus however long until the agent gets around to its next real action, and that second part isn't a fixed system number at all. An agent moving faster, with no artificial pacing between steps or several tool calls requested in the same turn, gets caught just as fast on its very next call, without any extra mechanism needed to catch it sooner. The floor here is roughly one model round trip, not the enforcement check sitting underneath it.&lt;/p&gt;

&lt;p&gt;A webhook can also be missed or delayed, because that's what "best-effort delivery" actually means in practice, so I built a reconciliation sweep on top: a cron job checking every active user's live status directly against Kinde every five minutes. I tested it against a forced scenario: suspended a real user, then manually pushed the app's own record back to active, simulating a webhook that never arrived at all. The cron caught the drift and corrected it on its own, before I ever triggered a manual run.&lt;/p&gt;

&lt;h2&gt;
  
  
  Here's what I think
&lt;/h2&gt;

&lt;p&gt;"Revocation" is the wrong word for what any of this does, and I think that matters more than it sounds like it should. Nothing here revokes anything. What actually happens is a live check gets bolted on top of a credential that was never revocable to begin with, and I'd rather describe it that way than pretend otherwise, because that's closer to what every system in this space is doing under the hood, whatever the marketing copy on top of it calls it.&lt;/p&gt;

&lt;p&gt;I'd also argue this check shouldn't be the thing you bolt on during a hardening pass once the demo already works, which is exactly the order it happened in during this build. The seam existed early on, but the reconciliation backstop and the timestamp check both came later, as production polish, and I think that ordering is backwards for anything that runs unattended. A browser session gets re-validated more or less by accident, on every page load, because there's a human sitting there generating new requests the whole time. An agent loop doesn't get that for free. It holds one credential, validated once at the start, and then it acts on that credential in a loop nobody's watching in real time. If the check isn't built into that loop from the first line, there's no accident later that adds it back in.&lt;/p&gt;

&lt;p&gt;The usual objection to this is cost: a database read on every single tool call sounds expensive once an agent's task means dozens of calls instead of one. But this build's own numbers say that objection doesn't hold up, at least not with an indexed lookup like the one here. The check disappears next to webhook latency, and webhook latency itself disappears next to how long a person actually takes to notice someone's gone and go click suspend. The genuinely expensive part of this whole system is the five to fifteen minutes between someone walking out the door and someone else noticing. Nobody optimizes for that number, even though it's the one that actually decides how exposed you are.&lt;/p&gt;

&lt;h2&gt;
  
  
  What this doesn't solve
&lt;/h2&gt;

&lt;p&gt;The token-still-valid gap from the top of this piece is real, and nothing here closes it. This build works around it, by checking liveness on every call, instead of trying to make the token itself stop working. A system that actually needs the token revoked needs a different mechanism entirely, like short-lived tokens or an introspection endpoint hit on every use, and both of those trade away the exact stateless-verification benefit that made a signed token worth using in the first place.&lt;/p&gt;

&lt;p&gt;The reconciliation sweep is a five-minute backstop, not the primary path, and if the webhook's doing its job, the sweep never finds anything to correct.&lt;/p&gt;

&lt;p&gt;&lt;code&gt;runs.timeline&lt;/code&gt;, &lt;code&gt;runs.get&lt;/code&gt;, and the audit queries in this build are also unauthenticated Convex reads. A run id or a correlation id is enough on its own to read that run's whole timeline, which is fine for a single-user demo console and not fine at all for anything with untrusted users in it.&lt;/p&gt;

&lt;p&gt;And the action registry here covers exactly three read and write actions on a demo resource. That's enough to prove the pattern holds. It's nowhere near enough to prove the pattern scales to a real authorization model with real permission boundaries between real resources.&lt;/p&gt;

&lt;h2&gt;
  
  
  Where this leaves the agent
&lt;/h2&gt;

&lt;p&gt;Back to the opening scene: someone gets offboarded, and their agent is mid-task. Whether its next few tool calls go through was never actually a question about the token. The token was always going to keep working right up until it expired on its own, offboarding or not, because that's just what it is. The real question was always whether anything standing between the agent and the action it's about to take bothered to check, right then, whether the person behind it was still around.&lt;/p&gt;

&lt;h2&gt;
  
  
  Code and sources
&lt;/h2&gt;

&lt;p&gt;The full build, the enforcement seam, the webhook handler, the reconciliation cron, and &lt;code&gt;scripts/e2e-narrative.ts&lt;/code&gt;, is on GitHub: &lt;a href="https://github.com/sholajegede/offboarding-revocation-demo" rel="noopener noreferrer"&gt;sholajegede/offboarding-revocation-demo&lt;/a&gt;.&lt;/p&gt;

&lt;p&gt;The webhook signing and delivery model comes from Kinde's own &lt;a href="https://docs.kinde.com/integrate/webhooks/about-webhooks/" rel="noopener noreferrer"&gt;webhooks documentation&lt;/a&gt;, and the suspend and restore actions used throughout the demo follow Kinde's &lt;a href="https://docs.kinde.com/kinde-apis/management/" rel="noopener noreferrer"&gt;Management API&lt;/a&gt; and its notes on &lt;a href="https://docs.kinde.com/manage-users/access-control/delete-or-suspend-users/" rel="noopener noreferrer"&gt;suspending and deleting users&lt;/a&gt;.&lt;/p&gt;

&lt;p&gt;The reconciliation sweep runs on Convex's &lt;a href="https://docs.convex.dev/scheduling/cron-jobs" rel="noopener noreferrer"&gt;cron jobs&lt;/a&gt;, and the agent loop follows Anthropic's &lt;a href="https://platform.claude.com/docs/en/agents-and-tools/tool-use/overview" rel="noopener noreferrer"&gt;tool use documentation&lt;/a&gt; for the Messages API. Every number in this piece came from &lt;code&gt;runs.timeline&lt;/code&gt; and &lt;code&gt;auditLog&lt;/code&gt; in that same Convex deployment, read straight off the live runs.&lt;/p&gt;

&lt;p&gt;Clone it, wire it up against &lt;a href="https://kinde.com/?utm_source=devto&amp;amp;utm_medium=content&amp;amp;utm_campaign=shola&amp;amp;campaignid=chatgptapp&amp;amp;network=&amp;amp;adgroup=&amp;amp;keyword=&amp;amp;matchtype=&amp;amp;creative=24&amp;amp;device=&amp;amp;adposition=" rel="noopener noreferrer"&gt;your own Kinde&lt;/a&gt; tenant, and offboard a test user mid-run. Your webhook latency might not match mine, your reconciliation sweep could catch different drift, and that's the point really. Drop your numbers in the comments and let me know what you find.&lt;/p&gt;

</description>
      <category>ai</category>
      <category>kinde</category>
      <category>webdev</category>
      <category>claude</category>
    </item>
    <item>
      <title>How to Let an OpenAI Agent Act in a User's GitHub Without Storing API Keys</title>
      <dc:creator>Shola Jegede</dc:creator>
      <pubDate>Fri, 14 Aug 2026 16:58:48 +0000</pubDate>
      <link>https://dev.to/sholajegede/how-to-let-an-openai-agent-act-in-a-users-github-without-storing-api-keys-jka</link>
      <guid>https://dev.to/sholajegede/how-to-let-an-openai-agent-act-in-a-users-github-without-storing-api-keys-jka</guid>
      <description>&lt;p&gt;Picture this: A user in your app connects their GitHub account. They ask the agent to deal with an issue, so it reads the thread, writes a comment that answers the question, and opens a pull request with the fix. That comment has to appear under the user's name, because their teammates need to see who acted. So you run an OAuth flow, take the access token GitHub returns, and put it in a column beside the user's row. Every action after that reads the column.&lt;/p&gt;

&lt;p&gt;Now look at what you just created. That column holds a standing secret. It works on the first day and it keeps working long after the reason for it has gone, because the user wanted one comment and the token stays until somebody deletes it. It carries whatever scopes were convenient when you set it up rather than the narrow thing the task needed. Worst of all, it multiplies. The token sits in your database, in last night's backup, in any log line that printed it during an incident, and in the memory of whichever worker read it a minute ago. When that user decides they want the agent out of their account, revocation turns into a hunt through all of those places, and you are asking them to trust that you will find every copy.&lt;/p&gt;

&lt;p&gt;In this tutorial, you'll build a demo that answers the other question: what if the app never holds the token at all? An OpenAI agent reads issues, writes comments, and opens pull requests inside a user's real GitHub account, while the app stores no GitHub token anywhere. &lt;a href="https://kinde.com/?utm_source=devto&amp;amp;utm_medium=content&amp;amp;utm_campaign=shola&amp;amp;campaignid=chatgptapp&amp;amp;network=&amp;amp;adgroup=&amp;amp;keyword=&amp;amp;matchtype=&amp;amp;creative=23&amp;amp;device=&amp;amp;adposition=" rel="noopener noreferrer"&gt;Kinde&lt;/a&gt; Connected Apps holds the GitHub authorization and the refresh token. A broker asks Kinde for a token at the moment of each action, uses it for that one call, and then drops it. The user cuts the agent off with one click.&lt;/p&gt;

&lt;p&gt;By the end, you'll have built:&lt;/p&gt;

&lt;ul&gt;
&lt;li&gt;&lt;p&gt;A token broker that every GitHub action passes through, with nothing else in the app calling GitHub&lt;/p&gt;&lt;/li&gt;
&lt;li&gt;&lt;p&gt;Two deployment modes, one that stores nothing and one that stores a standing key, so you can watch the difference&lt;/p&gt;&lt;/li&gt;
&lt;li&gt;&lt;p&gt;An OpenAI tool-calling loop that proposes actions and never sees a credential&lt;/p&gt;&lt;/li&gt;
&lt;li&gt;&lt;p&gt;A live connect flow through Kinde Connected Apps, and a kill switch that cuts the agent off&lt;/p&gt;&lt;/li&gt;
&lt;li&gt;&lt;p&gt;An audit trail where every brokered action, refusal, and revocation carries a correlation id&lt;/p&gt;&lt;/li&gt;
&lt;li&gt;&lt;p&gt;A runtime assertion that hunts for a stored credential after a real agent run&lt;/p&gt;&lt;/li&gt;
&lt;li&gt;&lt;p&gt;An end-to-end script that walks the whole story and exits non-zero when any check fails&lt;/p&gt;&lt;/li&gt;
&lt;/ul&gt;

&lt;p&gt;Everything in this article comes from running that build against live services. Every number, reason code, and token shape below is measured, including one result that weakens the easy version of the story.&lt;/p&gt;

&lt;h2&gt;
  
  
  Table of Contents
&lt;/h2&gt;

&lt;ul&gt;
&lt;li&gt;What You'll Build&lt;/li&gt;
&lt;li&gt;Prerequisites&lt;/li&gt;
&lt;li&gt;How the Demo Is Structured&lt;/li&gt;
&lt;li&gt;Why a Stored Key Fails&lt;/li&gt;
&lt;li&gt;The Connected Apps Primitive&lt;/li&gt;
&lt;li&gt;How to Connect a User's GitHub&lt;/li&gt;
&lt;li&gt;How to Build the Broker&lt;/li&gt;
&lt;li&gt;How to Model the Data&lt;/li&gt;
&lt;li&gt;How to Decide the Storage Mode&lt;/li&gt;
&lt;li&gt;How to Build the Agent Loop&lt;/li&gt;
&lt;li&gt;Two Revocation Levers, and One Is a Decoy&lt;/li&gt;
&lt;li&gt;The Central Finding: Revocation Reaches the Connection, Not the Token&lt;/li&gt;
&lt;li&gt;The Two Modes, Side by Side&lt;/li&gt;
&lt;li&gt;How to Keep Least Privilege&lt;/li&gt;
&lt;li&gt;What the Operator Controls&lt;/li&gt;
&lt;li&gt;How to Harden It for Production&lt;/li&gt;
&lt;li&gt;How to Prove the Tests Can Fail&lt;/li&gt;
&lt;li&gt;Honest Limitations&lt;/li&gt;
&lt;li&gt;What This Design Costs&lt;/li&gt;
&lt;li&gt;What You've Built, and Where to Take It&lt;/li&gt;
&lt;li&gt;Wrapping Up&lt;/li&gt;
&lt;li&gt;Resources&lt;/li&gt;
&lt;/ul&gt;

&lt;h2&gt;
  
  
  What You'll Build
&lt;/h2&gt;

&lt;p&gt;The operator signs in, connects GitHub, and writes a task in plain text. The console shows which deployment mode is running, how many tokens the app keeps, and what the agent has done so far.&lt;/p&gt;

&lt;p&gt;&lt;a href="https://media2.dev.to/dynamic/image/width=800%2Cheight=%2Cfit=scale-down%2Cgravity=auto%2Cformat=auto/https%3A%2F%2Fdev-to-uploads.s3.us-east-2.amazonaws.com%2Fuploads%2Farticles%2Fjxss90cnv51o11itn4fj.png" class="article-body-image-wrapper"&gt;&lt;img src="https://media2.dev.to/dynamic/image/width=800%2Cheight=%2Cfit=scale-down%2Cgravity=auto%2Cformat=auto/https%3A%2F%2Fdev-to-uploads.s3.us-east-2.amazonaws.com%2Fuploads%2Farticles%2Fjxss90cnv51o11itn4fj.png" alt="The operator console. The header shows the signed in user and a badge reading STORAGE_MODE: connected-app. A counts panel shows tokens supplied, GitHub tokens that the app keeps at zero, actions done on GitHub, actions refused, and actions after revocation. The GitHub connection panel shows the status linked, the acting GitHub login, the public_repo scope, and a Revoke connection button." width="799" height="404"&gt;&lt;/a&gt;&lt;/p&gt;

&lt;p&gt;Every step of a run streams into a live timeline. The agent asks for a tool, the broker performs it, and each row carries the correlation id that ties the timeline to the audit trail.&lt;/p&gt;

&lt;p&gt;&lt;a href="https://media2.dev.to/dynamic/image/width=800%2Cheight=%2Cfit=scale-down%2Cgravity=auto%2Cformat=auto/https%3A%2F%2Fdev-to-uploads.s3.us-east-2.amazonaws.com%2Fuploads%2Farticles%2Foaixk8nolm1jx8byj1we.png" class="article-body-image-wrapper"&gt;&lt;img src="https://media2.dev.to/dynamic/image/width=800%2Cheight=%2Cfit=scale-down%2Cgravity=auto%2Cformat=auto/https%3A%2F%2Fdev-to-uploads.s3.us-east-2.amazonaws.com%2Fuploads%2Farticles%2Foaixk8nolm1jx8byj1we.png" alt="The live timeline in the console. The run status reads succeeded, with the model id shown. The rows read task received, tool requested read_issues, broker executed reading three open issues, tool requested read_issue, broker executed reading issue three, and tool requested comment_issue. Every row shows the same correlation id." width="799" height="404"&gt;&lt;/a&gt;&lt;/p&gt;

&lt;p&gt;That run posted a real comment on a real issue, written by the signed-in GitHub account, while the app stored nothing.&lt;/p&gt;

&lt;p&gt;Then the operator clicks Revoke. The GitHub connection is cut, the next action the agent asks for is refused, and the operator stays signed in to the app. You'll see both of those later, once the code behind them is in place.&lt;/p&gt;

&lt;h2&gt;
  
  
  Prerequisites
&lt;/h2&gt;

&lt;p&gt;To follow along, you'll need:&lt;/p&gt;

&lt;p&gt;Node 18 or later, and comfort with TypeScript. You don't need to be an expert, but you should be comfortable reading async/await and typed function signatures.&lt;/p&gt;

&lt;p&gt;&lt;a href="https://kinde.com/?utm_source=devto&amp;amp;utm_medium=content&amp;amp;utm_campaign=shola&amp;amp;campaignid=chatgptapp&amp;amp;network=&amp;amp;adgroup=&amp;amp;keyword=&amp;amp;matchtype=&amp;amp;creative=23&amp;amp;device=&amp;amp;adposition=" rel="noopener noreferrer"&gt;A Kinde account&lt;/a&gt;. The free tier covers everything here. You'll create a back-end web app for sign-in, a machine-to-machine app for the Management API, and GitHub as a connected app.&lt;/p&gt;

&lt;p&gt;A GitHub account, and a repository you're happy to write to. The agent posts real comments, so use a throwaway repository rather than anything that matters.&lt;/p&gt;

&lt;p&gt;An OpenAI API key, and a model that supports tool calls.&lt;/p&gt;

&lt;p&gt;A Convex account for the data store. The free tier is enough.&lt;/p&gt;

&lt;p&gt;Basic familiarity with OAuth helps but is not required, since the whole point of this build is that Kinde runs that flow for you.&lt;/p&gt;

&lt;h2&gt;
  
  
  How the Demo Is Structured
&lt;/h2&gt;

&lt;p&gt;Every GitHub action follows the same path, and the storage mode decides only where the credential comes from:&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight plaintext"&gt;&lt;code&gt;operator writes a task
        │
        ▼
   OpenAI agent  ──── proposes an action, holds no token
        │
        ▼
     BROKER  ─────────┬─── connected-app: ask Kinde, use once, discard
        │             └─── stored-key:   use the token the app keeps
        ▼
     GitHub  ──── the comment appears as the user
        │
        ▼
   audit row  ──── correlation id, storage mode, outcome
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;The broker is the only module that obtains a credential, and the only module that calls GitHub. The agent sits above it and never touches either. A test walks the source tree and fails the build if a second GitHub caller ever appears.&lt;/p&gt;

&lt;h2&gt;
  
  
  Why a Stored Key Fails
&lt;/h2&gt;

&lt;p&gt;This shape is now common enough that the largest AI vendor ships it. Company knowledge in ChatGPT connects to Slack, SharePoint, Google Drive, and GitHub, and answers from the data in those accounts (&lt;a href="https://openai.com/index/introducing-company-knowledge/" rel="noopener noreferrer"&gt;OpenAI&lt;/a&gt;). Workspace agents go further, because they run repeatable workflows, use connected apps, and complete tasks end to end (&lt;a href="https://openai.com/index/introducing-workspace-agents-in-chatgpt/" rel="noopener noreferrer"&gt;OpenAI&lt;/a&gt;). Agents act inside a user's own third party accounts today, so the credential question stops being a detail you can defer.&lt;/p&gt;

&lt;p&gt;Two of the costs are measurable, and this build measured both.&lt;/p&gt;

&lt;p&gt;&lt;strong&gt;The stored key carries far more access than the work needs.&lt;/strong&gt; A token that a developer already had is the token that gets used. In this build the stored key carried &lt;code&gt;gist&lt;/code&gt;, &lt;code&gt;read:org&lt;/code&gt;, &lt;code&gt;repo&lt;/code&gt;, and &lt;code&gt;workflow&lt;/code&gt;. The four actions in the demo need &lt;code&gt;public_repo&lt;/code&gt; and nothing else. The broker recorded that gap on every action, in the audit row itself.&lt;/p&gt;

&lt;p&gt;&lt;strong&gt;Central revocation does not reach it.&lt;/strong&gt; The app holds the token, so nothing you do at your identity provider touches it. I revoked the connection and the agent carried on writing to the repository for another four and a half minutes, and it would have carried on for hours.&lt;/p&gt;

&lt;p&gt;The fix is not a better column, or tighter access control on the column. The fix is to stop holding the credential.&lt;/p&gt;

&lt;p&gt;&lt;a href="https://media2.dev.to/dynamic/image/width=800%2Cheight=%2Cfit=scale-down%2Cgravity=auto%2Cformat=auto/https%3A%2F%2Fdev-to-uploads.s3.us-east-2.amazonaws.com%2Fuploads%2Farticles%2Fodp8lue0l27yjv3yaenp.png" class="article-body-image-wrapper"&gt;&lt;img src="https://media2.dev.to/dynamic/image/width=800%2Cheight=%2Cfit=scale-down%2Cgravity=auto%2Cformat=auto/https%3A%2F%2Fdev-to-uploads.s3.us-east-2.amazonaws.com%2Fuploads%2Farticles%2Fodp8lue0l27yjv3yaenp.png" alt="One revocation, and what each mode does next. Kinde stops brokering at once, so the connected-app broker gets INVALID_SESSION on its next request and the agent stops. The stored-key app never asks Kinde, so it keeps acting and its comment lands after the revocation. GitHub is the third party in this picture, and it honours the token it already issued until that token expires." width="799" height="359"&gt;&lt;/a&gt;&lt;/p&gt;

&lt;p&gt;&lt;em&gt;Where the token lives, in each mode. In &lt;code&gt;stored-key&lt;/code&gt; the app keeps a long-life token in its own database and calls GitHub with it. In &lt;code&gt;connected-app&lt;/code&gt; the app keeps nothing, so it asks Kinde for one short-life token for each action. The second path has one more hop, and that hop is the whole difference.&lt;/em&gt;&lt;/p&gt;

&lt;h2&gt;
  
  
  The Connected Apps Primitive
&lt;/h2&gt;

&lt;p&gt;Kinde Connected Apps let your users reach third party tools through Kinde (&lt;a href="https://docs.kinde.com/integrate/connected-apps/add-connected-apps/" rel="noopener noreferrer"&gt;Kinde docs&lt;/a&gt;). Kinde holds the GitHub authorization. Your app asks Kinde for a token when it needs one.&lt;/p&gt;

&lt;p&gt;Three Management API endpoints matter. I read them from the published OpenAPI specification rather than from memory, because the documentation pages do not carry the parameter detail.&lt;/p&gt;

&lt;div class="table-wrapper-paragraph"&gt;&lt;table&gt;
&lt;thead&gt;
&lt;tr&gt;
&lt;th&gt;Operation&lt;/th&gt;
&lt;th&gt;Method and path&lt;/th&gt;
&lt;th&gt;Parameters&lt;/th&gt;
&lt;/tr&gt;
&lt;/thead&gt;
&lt;tbody&gt;
&lt;tr&gt;
&lt;td&gt;Get the auth URL&lt;/td&gt;
&lt;td&gt;&lt;code&gt;GET /api/v1/connected_apps/auth_url&lt;/code&gt;&lt;/td&gt;
&lt;td&gt;
&lt;code&gt;key_code_ref&lt;/code&gt; (required), &lt;code&gt;user_id&lt;/code&gt;, &lt;code&gt;org_code&lt;/code&gt;, &lt;code&gt;override_callback_url&lt;/code&gt;
&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;Get a token&lt;/td&gt;
&lt;td&gt;&lt;code&gt;GET /api/v1/connected_apps/token&lt;/code&gt;&lt;/td&gt;
&lt;td&gt;
&lt;code&gt;session_id&lt;/code&gt; (required)&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;Revoke&lt;/td&gt;
&lt;td&gt;&lt;code&gt;POST /api/v1/connected_apps/revoke&lt;/code&gt;&lt;/td&gt;
&lt;td&gt;
&lt;code&gt;session_id&lt;/code&gt; (required)&lt;/td&gt;
&lt;/tr&gt;
&lt;/tbody&gt;
&lt;/table&gt;&lt;/div&gt;

&lt;p&gt;The token response carries two fields and nothing else: &lt;code&gt;access_token&lt;/code&gt; and &lt;code&gt;access_token_expiry&lt;/code&gt;. The schema has no refresh token field. My app never receives one.&lt;/p&gt;

&lt;p&gt;Note the first parameter name. It is &lt;code&gt;key_code_ref&lt;/code&gt;, a reference string that you set on the connected app in the Kinde dashboard. It is not a connection id. I had assumed otherwise and had to rename an environment variable.&lt;/p&gt;

&lt;p&gt;Your app keeps one value from this flow: the &lt;code&gt;session_id&lt;/code&gt;. Kinde returns it on the callback. That handle is not a GitHub credential. It grants nothing on its own, it needs your Kinde machine to machine credentials to be useful, and Kinde stops honouring it the moment you revoke the connection.&lt;/p&gt;

&lt;p&gt;In the &lt;a href="https://kinde.com/?utm_source=devto&amp;amp;utm_medium=content&amp;amp;utm_campaign=shola&amp;amp;campaignid=chatgptapp&amp;amp;network=&amp;amp;adgroup=&amp;amp;keyword=&amp;amp;matchtype=&amp;amp;creative=23&amp;amp;device=&amp;amp;adposition=" rel="noopener noreferrer"&gt;Kinde dashboard&lt;/a&gt;, the connected app carries a name, the GitHub client id, the GitHub client secret, and a key. That last field is the &lt;code&gt;key_code_ref&lt;/code&gt; your code sends, and here it reads &lt;code&gt;github&lt;/code&gt;.&lt;/p&gt;

&lt;p&gt;&lt;a href="https://media2.dev.to/dynamic/image/width=800%2Cheight=%2Cfit=scale-down%2Cgravity=auto%2Cformat=auto/https%3A%2F%2Fdev-to-uploads.s3.us-east-2.amazonaws.com%2Fuploads%2Farticles%2Fsltcsoqhxjw9feu602hi.png" class="article-body-image-wrapper"&gt;&lt;img src="https://media2.dev.to/dynamic/image/width=800%2Cheight=%2Cfit=scale-down%2Cgravity=auto%2Cformat=auto/https%3A%2F%2Fdev-to-uploads.s3.us-east-2.amazonaws.com%2Fuploads%2Farticles%2Fsltcsoqhxjw9feu602hi.png" alt="The Configure GitHub dialog in the Kinde dashboard, on the Details tab. It shows a Name field reading GitHub, a Client ID field, a Client secret field masked with dots, and a Key field reading github." width="800" height="405"&gt;&lt;/a&gt;&lt;/p&gt;

&lt;p&gt;The Permissions tab is where least privilege gets set. The Scopes box holds one chip, &lt;code&gt;public_repo&lt;/code&gt;, and every other GitHub scope below it stays switched off.&lt;/p&gt;

&lt;p&gt;&lt;a href="https://media2.dev.to/dynamic/image/width=800%2Cheight=%2Cfit=scale-down%2Cgravity=auto%2Cformat=auto/https%3A%2F%2Fdev-to-uploads.s3.us-east-2.amazonaws.com%2Fuploads%2Farticles%2Fu19p324k1k8fkx476g9a.png" class="article-body-image-wrapper"&gt;&lt;img src="https://media2.dev.to/dynamic/image/width=800%2Cheight=%2Cfit=scale-down%2Cgravity=auto%2Cformat=auto/https%3A%2F%2Fdev-to-uploads.s3.us-east-2.amazonaws.com%2Fuploads%2Farticles%2Fu19p324k1k8fkx476g9a.png" alt="The Configure GitHub dialog in the Kinde dashboard, on the Permissions tab. Under a Scopes heading, one chip reads public_repo. Below it, a searchable list shows GitHub scopes such as admin:gpg_key and admin:org, each with its toggle switched off." width="800" height="405"&gt;&lt;/a&gt;&lt;/p&gt;

&lt;p&gt;Search that list for a user scope and nothing comes back, which is why this build reads the acting GitHub login from the API response instead.&lt;/p&gt;

&lt;p&gt;The user sees GitHub's own consent screen, and it asks for exactly what Kinde requested.&lt;/p&gt;

&lt;p&gt;&lt;a href="https://media2.dev.to/dynamic/image/width=800%2Cheight=%2Cfit=scale-down%2Cgravity=auto%2Cformat=auto/https%3A%2F%2Fdev-to-uploads.s3.us-east-2.amazonaws.com%2Fuploads%2Farticles%2Fptof77rnxltrszuy4mmj.jpg" class="article-body-image-wrapper"&gt;&lt;img src="https://media2.dev.to/dynamic/image/width=800%2Cheight=%2Cfit=scale-down%2Cgravity=auto%2Cformat=auto/https%3A%2F%2Fdev-to-uploads.s3.us-east-2.amazonaws.com%2Fuploads%2Farticles%2Fptof77rnxltrszuy4mmj.jpg" alt="GitHub's authorization screen, headed Authorize Connected Apps GitHub Demo. A Repositories row reads Public repositories, which is the public_repo scope and the only permission requested. An Organization access block lists four organizations, each with a Request or Grant button that stays untouched. Buttons at the bottom read Cancel and Authorize sholajegede." width="800" height="431"&gt;&lt;/a&gt;&lt;/p&gt;

&lt;p&gt;The Repositories row reads "Public repositories" and nothing else, which is the &lt;code&gt;public_repo&lt;/code&gt; scope reaching the user. The Organization access block is separate: GitHub offers it on every OAuth consent screen, and this demo grants none of it.&lt;/p&gt;

&lt;h2&gt;
  
  
  How to Connect a User's GitHub
&lt;/h2&gt;

&lt;p&gt;The connect flow has three parts, and your app stores one value from it.&lt;/p&gt;

&lt;p&gt;First, ask Kinde for an authorization URL for this specific user.&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight typescript"&gt;&lt;code&gt;&lt;span class="k"&gt;export&lt;/span&gt; &lt;span class="k"&gt;async&lt;/span&gt; &lt;span class="kd"&gt;function&lt;/span&gt; &lt;span class="nf"&gt;getConnectedAppAuthUrl&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="nx"&gt;options&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="p"&gt;{&lt;/span&gt;
  &lt;span class="nl"&gt;kindeUserId&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="kr"&gt;string&lt;/span&gt;&lt;span class="p"&gt;;&lt;/span&gt;
  &lt;span class="nl"&gt;overrideCallbackUrl&lt;/span&gt;&lt;span class="p"&gt;?:&lt;/span&gt; &lt;span class="kr"&gt;string&lt;/span&gt;&lt;span class="p"&gt;;&lt;/span&gt;
  &lt;span class="nl"&gt;token&lt;/span&gt;&lt;span class="p"&gt;?:&lt;/span&gt; &lt;span class="kr"&gt;string&lt;/span&gt;&lt;span class="p"&gt;;&lt;/span&gt;
&lt;span class="p"&gt;}):&lt;/span&gt; &lt;span class="nb"&gt;Promise&lt;/span&gt;&lt;span class="o"&gt;&amp;lt;&lt;/span&gt;&lt;span class="nx"&gt;ConnectedAppAuthUrl&lt;/span&gt;&lt;span class="o"&gt;&amp;gt;&lt;/span&gt; &lt;span class="p"&gt;{&lt;/span&gt;
  &lt;span class="kd"&gt;const&lt;/span&gt; &lt;span class="nx"&gt;env&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="nf"&gt;kindeManagementEnv&lt;/span&gt;&lt;span class="p"&gt;();&lt;/span&gt;

  &lt;span class="kd"&gt;const&lt;/span&gt; &lt;span class="nx"&gt;response&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="k"&gt;await&lt;/span&gt; &lt;span class="nx"&gt;managementRequest&lt;/span&gt;&lt;span class="o"&gt;&amp;lt;&lt;/span&gt;&lt;span class="p"&gt;{&lt;/span&gt;
    &lt;span class="nx"&gt;url&lt;/span&gt;&lt;span class="p"&gt;?:&lt;/span&gt; &lt;span class="kr"&gt;string&lt;/span&gt;&lt;span class="p"&gt;;&lt;/span&gt;
    &lt;span class="nl"&gt;session_id&lt;/span&gt;&lt;span class="p"&gt;?:&lt;/span&gt; &lt;span class="kr"&gt;string&lt;/span&gt;&lt;span class="p"&gt;;&lt;/span&gt;
  &lt;span class="p"&gt;}&lt;/span&gt;&lt;span class="o"&gt;&amp;gt;&lt;/span&gt;&lt;span class="p"&gt;({&lt;/span&gt;
    &lt;span class="na"&gt;path&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="nx"&gt;AUTH_URL_PATH&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt;
    &lt;span class="na"&gt;query&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="p"&gt;{&lt;/span&gt;
      &lt;span class="na"&gt;key_code_ref&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="nx"&gt;env&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nx"&gt;KINDE_GITHUB_CONNECTED_APP_KEY&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt;
      &lt;span class="na"&gt;user_id&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="nx"&gt;options&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nx"&gt;kindeUserId&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt;
      &lt;span class="na"&gt;override_callback_url&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="nx"&gt;options&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nx"&gt;overrideCallbackUrl&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt;
    &lt;span class="p"&gt;},&lt;/span&gt;
    &lt;span class="na"&gt;token&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="nx"&gt;options&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nx"&gt;token&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="p"&gt;(&lt;/span&gt;&lt;span class="o"&gt;!&lt;/span&gt;&lt;span class="nx"&gt;response&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nx"&gt;ok&lt;/span&gt; &lt;span class="o"&gt;||&lt;/span&gt; &lt;span class="o"&gt;!&lt;/span&gt;&lt;span class="nx"&gt;response&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nx"&gt;body&lt;/span&gt;&lt;span class="p"&gt;?.&lt;/span&gt;&lt;span class="nx"&gt;url&lt;/span&gt; &lt;span class="o"&gt;||&lt;/span&gt; &lt;span class="o"&gt;!&lt;/span&gt;&lt;span class="nx"&gt;response&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nx"&gt;body&lt;/span&gt;&lt;span class="p"&gt;?.&lt;/span&gt;&lt;span class="nx"&gt;session_id&lt;/span&gt;&lt;span class="p"&gt;)&lt;/span&gt; &lt;span class="p"&gt;{&lt;/span&gt;
    &lt;span class="k"&gt;throw&lt;/span&gt; &lt;span class="k"&gt;new&lt;/span&gt; &lt;span class="nc"&gt;Error&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;
      &lt;span class="s2"&gt;`Kinde did not return a connected app auth url (HTTP &lt;/span&gt;&lt;span class="p"&gt;${&lt;/span&gt;&lt;span class="nx"&gt;response&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nx"&gt;status&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;span class="k"&gt;return&lt;/span&gt; &lt;span class="p"&gt;{&lt;/span&gt; &lt;span class="na"&gt;url&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="nx"&gt;response&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nx"&gt;body&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nx"&gt;url&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="na"&gt;sessionId&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="nx"&gt;response&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nx"&gt;body&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nx"&gt;session_id&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;Second, send the user to that URL. GitHub shows its own consent screen. The URL that Kinde generated for my demo requested &lt;code&gt;scope=public_repo&lt;/code&gt; and nothing more, and GitHub's screen showed "Public repositories" only.&lt;/p&gt;

&lt;p&gt;Third, Kinde returns the user to your callback with the session handle in the query string:&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight console"&gt;&lt;code&gt;&lt;span class="go"&gt;/api/connect/github/callback?session_id=dc5e2e23f19749f08c1f73b4ee7af8c2
&lt;/span&gt;&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;Store that handle against the user. My callback route marks the connection linked and writes an audit row. No token arrives in this request, and the route stores none.&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight typescript"&gt;&lt;code&gt;&lt;span class="k"&gt;await&lt;/span&gt; &lt;span class="nx"&gt;convex&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nf"&gt;mutation&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="nx"&gt;api&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nx"&gt;gateway&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nx"&gt;markLinked&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="p"&gt;{&lt;/span&gt;
  &lt;span class="nx"&gt;secret&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt;
  &lt;span class="na"&gt;userId&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="nx"&gt;operator&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nx"&gt;userId&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt;
  &lt;span class="na"&gt;kindeConnectionId&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="nf"&gt;kindeManagementEnv&lt;/span&gt;&lt;span class="p"&gt;().&lt;/span&gt;&lt;span class="nx"&gt;KINDE_GITHUB_CONNECTED_APP_KEY&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt;
  &lt;span class="na"&gt;kindeSessionId&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="nx"&gt;sessionId&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt;
  &lt;span class="na"&gt;grantedScopes&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="p"&gt;[&lt;/span&gt;&lt;span class="dl"&gt;"&lt;/span&gt;&lt;span class="s2"&gt;public_repo&lt;/span&gt;&lt;span class="dl"&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;Be clear with yourself about what this handle is. It identifies a connected app session at Kinde. It carries no GitHub access on its own. Somebody who steals it still needs your Kinde machine to machine credentials to turn it into a token, and Kinde stops honouring it the moment you revoke.&lt;/p&gt;

&lt;h2&gt;
  
  
  How to Build the Broker
&lt;/h2&gt;

&lt;p&gt;The broker is one function. Every GitHub action in the app passes through it. Nothing else in the app calls GitHub.&lt;/p&gt;

&lt;p&gt;Start with the credential step, because that is where the two modes differ.&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight typescript"&gt;&lt;code&gt;&lt;span class="k"&gt;export&lt;/span&gt; &lt;span class="k"&gt;async&lt;/span&gt; &lt;span class="kd"&gt;function&lt;/span&gt; &lt;span class="nf"&gt;acquireConnectedAppToken&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;
  &lt;span class="nx"&gt;source&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="nx"&gt;ConnectedAppSource&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt;
&lt;span class="p"&gt;):&lt;/span&gt; &lt;span class="nb"&gt;Promise&lt;/span&gt;&lt;span class="o"&gt;&amp;lt;&lt;/span&gt;&lt;span class="nx"&gt;CredentialResult&lt;/span&gt;&lt;span class="o"&gt;&amp;gt;&lt;/span&gt; &lt;span class="p"&gt;{&lt;/span&gt;
  &lt;span class="c1"&gt;// Fail closed before Kinde is even asked.&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;source&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nx"&gt;status&lt;/span&gt; &lt;span class="o"&gt;!==&lt;/span&gt; &lt;span class="dl"&gt;"&lt;/span&gt;&lt;span class="s2"&gt;linked&lt;/span&gt;&lt;span class="dl"&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="p"&gt;{&lt;/span&gt;
      &lt;span class="na"&gt;ok&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="kc"&gt;false&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt;
      &lt;span class="na"&gt;status&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="mi"&gt;0&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt;
      &lt;span class="na"&gt;reason&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="s2"&gt;`The GitHub connection is &lt;/span&gt;&lt;span class="p"&gt;${&lt;/span&gt;&lt;span class="nx"&gt;source&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nx"&gt;status&lt;/span&gt;&lt;span class="p"&gt;}&lt;/span&gt;&lt;span class="s2"&gt;. Refusing to act.`&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;if &lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="o"&gt;!&lt;/span&gt;&lt;span class="nx"&gt;source&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nx"&gt;kindeSessionId&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="p"&gt;{&lt;/span&gt;
      &lt;span class="na"&gt;ok&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="kc"&gt;false&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt;
      &lt;span class="na"&gt;status&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="mi"&gt;0&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt;
      &lt;span class="na"&gt;reason&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="dl"&gt;"&lt;/span&gt;&lt;span class="s2"&gt;No connected app session for this user. Refusing to act.&lt;/span&gt;&lt;span class="dl"&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;span class="kd"&gt;const&lt;/span&gt; &lt;span class="nx"&gt;result&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="k"&gt;await&lt;/span&gt; &lt;span class="nf"&gt;fetchConnectedAppToken&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="nx"&gt;source&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nx"&gt;kindeSessionId&lt;/span&gt;&lt;span class="p"&gt;);&lt;/span&gt;
  &lt;span class="k"&gt;if &lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="o"&gt;!&lt;/span&gt;&lt;span class="nx"&gt;result&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nx"&gt;ok&lt;/span&gt;&lt;span class="p"&gt;)&lt;/span&gt; &lt;span class="p"&gt;{&lt;/span&gt;
    &lt;span class="c1"&gt;// INVALID_SESSION lands here after a revocation.&lt;/span&gt;
    &lt;span class="k"&gt;return&lt;/span&gt; &lt;span class="p"&gt;{&lt;/span&gt; &lt;span class="na"&gt;ok&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="kc"&gt;false&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="na"&gt;status&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="nx"&gt;result&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nx"&gt;status&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="na"&gt;reason&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="nx"&gt;result&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nx"&gt;reason&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="p"&gt;{&lt;/span&gt; &lt;span class="na"&gt;ok&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="kc"&gt;true&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="na"&gt;token&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="nx"&gt;result&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nx"&gt;token&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nx"&gt;accessToken&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="na"&gt;source&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="nx"&gt;CONNECTED_APP&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;Now the stored key path. Read what it does not do.&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight typescript"&gt;&lt;code&gt;&lt;span class="k"&gt;export&lt;/span&gt; &lt;span class="kd"&gt;function&lt;/span&gt; &lt;span class="nf"&gt;acquireStoredKeyToken&lt;/span&gt;&lt;span class="p"&gt;():&lt;/span&gt; &lt;span class="nx"&gt;CredentialResult&lt;/span&gt; &lt;span class="p"&gt;{&lt;/span&gt;
  &lt;span class="k"&gt;try&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="na"&gt;ok&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="kc"&gt;true&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt;
      &lt;span class="na"&gt;token&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="nf"&gt;storedKeyEnv&lt;/span&gt;&lt;span class="p"&gt;().&lt;/span&gt;&lt;span class="nx"&gt;GITHUB_STORED_TOKEN&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt;
      &lt;span class="na"&gt;source&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="nx"&gt;STORED_KEY&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;catch&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="na"&gt;ok&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="kc"&gt;false&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt;
      &lt;span class="na"&gt;status&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="mi"&gt;0&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt;
      &lt;span class="na"&gt;reason&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt;
        &lt;span class="dl"&gt;"&lt;/span&gt;&lt;span class="s2"&gt;STORAGE_MODE is stored-key but GITHUB_STORED_TOKEN is not set. Refusing to act.&lt;/span&gt;&lt;span class="dl"&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;span class="p"&gt;}&lt;/span&gt;
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;This function never contacts Kinde. It never checks the connection status. That is not an oversight. It is the bug that the demo reproduces on purpose. Because nothing in this path asks Kinde, revocation at Kinde cannot reach it.&lt;/p&gt;

&lt;p&gt;The broker binds the token into a call function and hands the call function to the action handler.&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight typescript"&gt;&lt;code&gt;&lt;span class="k"&gt;export&lt;/span&gt; &lt;span class="kd"&gt;function&lt;/span&gt; &lt;span class="nf"&gt;createGitHubClient&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="nx"&gt;token&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="kr"&gt;string&lt;/span&gt;&lt;span class="p"&gt;):&lt;/span&gt; &lt;span class="nx"&gt;GitHubCall&lt;/span&gt; &lt;span class="p"&gt;{&lt;/span&gt;
  &lt;span class="k"&gt;return&lt;/span&gt; &lt;span class="k"&gt;async&lt;/span&gt; &lt;span class="kd"&gt;function&lt;/span&gt; &lt;span class="nf"&gt;call&lt;/span&gt;&lt;span class="o"&gt;&amp;lt;&lt;/span&gt;&lt;span class="nx"&gt;T&lt;/span&gt;&lt;span class="o"&gt;&amp;gt;&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="nx"&gt;path&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="nx"&gt;options&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="p"&gt;{})&lt;/span&gt; &lt;span class="p"&gt;{&lt;/span&gt;
    &lt;span class="kd"&gt;const&lt;/span&gt; &lt;span class="nx"&gt;response&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="k"&gt;await&lt;/span&gt; &lt;span class="nf"&gt;fetch&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="nx"&gt;url&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="p"&gt;{&lt;/span&gt;
      &lt;span class="na"&gt;method&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="nx"&gt;options&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nx"&gt;method&lt;/span&gt; &lt;span class="o"&gt;??&lt;/span&gt; &lt;span class="dl"&gt;"&lt;/span&gt;&lt;span class="s2"&gt;GET&lt;/span&gt;&lt;span class="dl"&gt;"&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt;
      &lt;span class="na"&gt;headers&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="p"&gt;{&lt;/span&gt;
        &lt;span class="na"&gt;authorization&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="s2"&gt;`Bearer &lt;/span&gt;&lt;span class="p"&gt;${&lt;/span&gt;&lt;span class="nx"&gt;token&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="na"&gt;accept&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="dl"&gt;"&lt;/span&gt;&lt;span class="s2"&gt;application/vnd.github+json&lt;/span&gt;&lt;span class="dl"&gt;"&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt;
        &lt;span class="dl"&gt;"&lt;/span&gt;&lt;span class="s2"&gt;x-github-api-version&lt;/span&gt;&lt;span class="dl"&gt;"&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="dl"&gt;"&lt;/span&gt;&lt;span class="s2"&gt;2022-11-28&lt;/span&gt;&lt;span class="dl"&gt;"&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt;
        &lt;span class="dl"&gt;"&lt;/span&gt;&lt;span class="s2"&gt;user-agent&lt;/span&gt;&lt;span class="dl"&gt;"&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="nx"&gt;USER_AGENT&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt;
      &lt;span class="p"&gt;},&lt;/span&gt;
      &lt;span class="na"&gt;cache&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="dl"&gt;"&lt;/span&gt;&lt;span class="s2"&gt;no-store&lt;/span&gt;&lt;span class="dl"&gt;"&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt;
      &lt;span class="na"&gt;signal&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="nx"&gt;AbortSignal&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nf"&gt;timeout&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="nx"&gt;GITHUB_TIMEOUT_MS&lt;/span&gt;&lt;span class="p"&gt;),&lt;/span&gt;
    &lt;span class="p"&gt;});&lt;/span&gt;
    &lt;span class="c1"&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;The token lives inside the closure. A handler receives &lt;code&gt;call&lt;/code&gt;, not the token. A handler therefore cannot read, copy, log, or store a credential, because it never holds one.&lt;/p&gt;

&lt;p&gt;The broker then runs the action and writes an audit row.&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight typescript"&gt;&lt;code&gt;&lt;span class="c1"&gt;// 4. Act. The handler is given a bound caller, never the token.&lt;/span&gt;
&lt;span class="kd"&gt;const&lt;/span&gt; &lt;span class="nx"&gt;target&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="nf"&gt;gitHubTargetEnv&lt;/span&gt;&lt;span class="p"&gt;();&lt;/span&gt;
&lt;span class="kd"&gt;const&lt;/span&gt; &lt;span class="nx"&gt;call&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="nf"&gt;createGitHubClient&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="nx"&gt;credential&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nx"&gt;token&lt;/span&gt;&lt;span class="p"&gt;);&lt;/span&gt;
&lt;span class="kd"&gt;const&lt;/span&gt; &lt;span class="nx"&gt;handler&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="nf"&gt;getHandler&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="nx"&gt;action&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nx"&gt;id&lt;/span&gt;&lt;span class="p"&gt;);&lt;/span&gt;

&lt;span class="kd"&gt;const&lt;/span&gt; &lt;span class="nx"&gt;result&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="k"&gt;await&lt;/span&gt; &lt;span class="nf"&gt;handler&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="nx"&gt;call&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="nx"&gt;input&lt;/span&gt; &lt;span class="k"&gt;as&lt;/span&gt; &lt;span class="nx"&gt;never&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="p"&gt;{&lt;/span&gt;
  &lt;span class="na"&gt;owner&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="nx"&gt;target&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nx"&gt;GITHUB_TARGET_OWNER&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt;
  &lt;span class="na"&gt;repo&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="nx"&gt;target&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nx"&gt;GITHUB_TARGET_REPO&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt;
&lt;span class="p"&gt;});&lt;/span&gt;

&lt;span class="k"&gt;await&lt;/span&gt; &lt;span class="nf"&gt;audit&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="dl"&gt;"&lt;/span&gt;&lt;span class="s2"&gt;action.invoked&lt;/span&gt;&lt;span class="dl"&gt;"&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="dl"&gt;"&lt;/span&gt;&lt;span class="s2"&gt;allowed&lt;/span&gt;&lt;span class="dl"&gt;"&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="nx"&gt;result&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nx"&gt;summary&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="nx"&gt;user&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nx"&gt;_id&lt;/span&gt;&lt;span class="p"&gt;);&lt;/span&gt;
&lt;span class="c1"&gt;// The token goes out of scope here. Nothing wrote it anywhere.&lt;/span&gt;
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;Here is the whole path, including every way it can end.&lt;/p&gt;

&lt;p&gt;&lt;a href="https://media2.dev.to/dynamic/image/width=800%2Cheight=%2Cfit=scale-down%2Cgravity=auto%2Cformat=auto/https%3A%2F%2Fdev-to-uploads.s3.us-east-2.amazonaws.com%2Fuploads%2Farticles%2F6cikl65jx6ghteyjf3nv.png" class="article-body-image-wrapper"&gt;&lt;img src="https://media2.dev.to/dynamic/image/width=800%2Cheight=%2Cfit=scale-down%2Cgravity=auto%2Cformat=auto/https%3A%2F%2Fdev-to-uploads.s3.us-east-2.amazonaws.com%2Fuploads%2Farticles%2F6cikl65jx6ghteyjf3nv.png" alt="Where the token lives, in each mode. In stored-key the app keeps a long-life token in its own database and calls GitHub with it. In connected-app the app keeps nothing, so it asks Kinde for one short-life token for each action. The second path has one more hop, and that hop is the whole difference." width="800" height="1250"&gt;&lt;/a&gt;&lt;/p&gt;

&lt;p&gt;&lt;em&gt;Every path an action can take through the broker. An unregistered name never reaches a credential. A missing or refused token ends in &lt;code&gt;token.refused&lt;/code&gt;. A GitHub error ends in a recorded failure. A success ends with the token going out of scope. Each ending writes an audit row, so no action leaves the broker without a record.&lt;/em&gt;&lt;/p&gt;

&lt;p&gt;Every path ends in an audit row. The row carries a correlation id, so the run timeline and the audit trail join on one value.&lt;/p&gt;

&lt;h2&gt;
  
  
  How to Model the Data
&lt;/h2&gt;

&lt;p&gt;The store has six tables: users, connections, actions, runs, run events, and the audit log.&lt;/p&gt;

&lt;p&gt;The connections table records state only. It holds the status, the GitHub login, the granted scopes, the linked and revoked timestamps, and a counter of how many tokens Kinde has supplied. It holds no credential in either mode.&lt;/p&gt;

&lt;p&gt;Writing that rule in a comment would prove nothing, so I wrote a test that walks the runtime validator tree of every table and fails on any field name that looks like a credential.&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight typescript"&gt;&lt;code&gt;&lt;span class="nf"&gt;it&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="dl"&gt;"&lt;/span&gt;&lt;span class="s2"&gt;holds no field that could store a GitHub credential&lt;/span&gt;&lt;span class="dl"&gt;"&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="p"&gt;()&lt;/span&gt; &lt;span class="o"&gt;=&amp;gt;&lt;/span&gt; &lt;span class="p"&gt;{&lt;/span&gt;
  &lt;span class="kd"&gt;const&lt;/span&gt; &lt;span class="nx"&gt;forbidden&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt;
    &lt;span class="sr"&gt;/&lt;/span&gt;&lt;span class="se"&gt;(&lt;/span&gt;&lt;span class="sr"&gt;access_&lt;/span&gt;&lt;span class="se"&gt;?&lt;/span&gt;&lt;span class="sr"&gt;token|refresh_&lt;/span&gt;&lt;span class="se"&gt;?&lt;/span&gt;&lt;span class="sr"&gt;token|^token$|bearer|secret|credential|client_&lt;/span&gt;&lt;span class="se"&gt;?&lt;/span&gt;&lt;span class="sr"&gt;secret|api_&lt;/span&gt;&lt;span class="se"&gt;?&lt;/span&gt;&lt;span class="sr"&gt;key|private_&lt;/span&gt;&lt;span class="se"&gt;?&lt;/span&gt;&lt;span class="sr"&gt;key&lt;/span&gt;&lt;span class="se"&gt;)&lt;/span&gt;&lt;span class="sr"&gt;/i&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="kd"&gt;const&lt;/span&gt; &lt;span class="p"&gt;[&lt;/span&gt;&lt;span class="nx"&gt;table&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="nx"&gt;definition&lt;/span&gt;&lt;span class="p"&gt;]&lt;/span&gt; &lt;span class="k"&gt;of&lt;/span&gt; &lt;span class="nb"&gt;Object&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nf"&gt;entries&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="nx"&gt;tables&lt;/span&gt;&lt;span class="p"&gt;))&lt;/span&gt; &lt;span class="p"&gt;{&lt;/span&gt;
    &lt;span class="kd"&gt;const&lt;/span&gt; &lt;span class="nx"&gt;names&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="p"&gt;[...&lt;/span&gt;&lt;span class="nf"&gt;fieldNames&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="nx"&gt;definition&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nx"&gt;validator&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;expect&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="nx"&gt;names&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nx"&gt;length&lt;/span&gt;&lt;span class="p"&gt;).&lt;/span&gt;&lt;span class="nf"&gt;toBeGreaterThan&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="mi"&gt;0&lt;/span&gt;&lt;span class="p"&gt;);&lt;/span&gt;
    &lt;span class="kd"&gt;const&lt;/span&gt; &lt;span class="nx"&gt;offenders&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="nx"&gt;names&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nf"&gt;filter&lt;/span&gt;&lt;span class="p"&gt;((&lt;/span&gt;&lt;span class="nx"&gt;name&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;forbidden&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;name&lt;/span&gt;&lt;span class="p"&gt;));&lt;/span&gt;
    &lt;span class="nf"&gt;expect&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;
      &lt;span class="nx"&gt;offenders&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt;
      &lt;span class="s2"&gt;`table "&lt;/span&gt;&lt;span class="p"&gt;${&lt;/span&gt;&lt;span class="nx"&gt;table&lt;/span&gt;&lt;span class="p"&gt;}&lt;/span&gt;&lt;span class="s2"&gt;" declares credential-shaped fields: &lt;/span&gt;&lt;span class="p"&gt;${&lt;/span&gt;&lt;span class="nx"&gt;offenders&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nf"&gt;join&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="dl"&gt;"&lt;/span&gt;&lt;span class="s2"&gt;, &lt;/span&gt;&lt;span class="dl"&gt;"&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="nf"&gt;toEqual&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;The test reads the compiled schema rather than the source text. Somebody who adds a token column later breaks the build, and they break it for a clear reason.&lt;/p&gt;

&lt;p&gt;The audit log carries the outcome of every decision. Six event types cover the whole surface: &lt;code&gt;connection.linked&lt;/code&gt;, &lt;code&gt;connection.revoked&lt;/code&gt;, &lt;code&gt;token.brokered&lt;/code&gt;, &lt;code&gt;token.refused&lt;/code&gt;, &lt;code&gt;action.invoked&lt;/code&gt;, and &lt;code&gt;action.refused&lt;/code&gt;. Three outcomes describe what happened: &lt;code&gt;allowed&lt;/code&gt;, &lt;code&gt;refused&lt;/code&gt;, and &lt;code&gt;failed&lt;/code&gt;.&lt;/p&gt;

&lt;p&gt;Every row also carries the storage mode. That single field is what lets the audit trail show both modes in one table and make the difference obvious.&lt;/p&gt;

&lt;p&gt;Everything that changes state is an internal mutation. The browser cannot forge an audit row, and it cannot flip a connection to linked. The server reaches those functions through one gateway that requires a shared secret, and the browser does not have that secret.&lt;/p&gt;

&lt;p&gt;One detail is worth copying. The broker writes a token row for each action rather than one row per run. A three action run produces three &lt;code&gt;token.brokered&lt;/code&gt; rows:&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight console"&gt;&lt;code&gt;&lt;span class="go"&gt;18:25:38  token.brokered  allowed  connected-app  Fetched a token from Kinde for one action. Not stored.
18:25:42  action.invoked  allowed  connected-app  Read 3 open issue(s) ...
18:25:47  token.brokered  allowed  connected-app  Fetched a token from Kinde for one action. Not stored.
&lt;/span&gt;&lt;span class="gp"&gt;18:25:50  action.invoked  allowed  connected-app  Read issue #&lt;/span&gt;1 ...
&lt;span class="go"&gt;18:25:59  token.brokered  allowed  connected-app  Fetched a token from Kinde for one action. Not stored.
&lt;/span&gt;&lt;span class="gp"&gt;18:26:01  action.invoked  allowed  connected-app  Commented on issue #&lt;/span&gt;1 as sholajegede.
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;Fetch per action stops being a claim in your README. It becomes a pattern in your data that anybody can count.&lt;/p&gt;

&lt;h2&gt;
  
  
  How to Decide the Storage Mode
&lt;/h2&gt;

&lt;p&gt;The mode must not be something a request can influence. I put the whole rule in one pure function.&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight typescript"&gt;&lt;code&gt;&lt;span class="k"&gt;export&lt;/span&gt; &lt;span class="kd"&gt;function&lt;/span&gt; &lt;span class="nf"&gt;resolveStorageMode&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="nx"&gt;raw&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="kr"&gt;string&lt;/span&gt; &lt;span class="o"&gt;|&lt;/span&gt; &lt;span class="kc"&gt;undefined&lt;/span&gt; &lt;span class="o"&gt;|&lt;/span&gt; &lt;span class="kc"&gt;null&lt;/span&gt;&lt;span class="p"&gt;):&lt;/span&gt; &lt;span class="nx"&gt;StorageMode&lt;/span&gt; &lt;span class="p"&gt;{&lt;/span&gt;
  &lt;span class="k"&gt;return&lt;/span&gt; &lt;span class="nx"&gt;raw&lt;/span&gt; &lt;span class="o"&gt;===&lt;/span&gt; &lt;span class="nx"&gt;STORED_KEY&lt;/span&gt; &lt;span class="p"&gt;?&lt;/span&gt; &lt;span class="nx"&gt;STORED_KEY&lt;/span&gt; &lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="nx"&gt;CONNECTED_APP&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;Only the exact string &lt;code&gt;stored-key&lt;/code&gt; selects the unsafe mode. Every other value resolves to &lt;code&gt;connected-app&lt;/code&gt;. That covers unset, empty, &lt;code&gt;STORED-KEY&lt;/code&gt;, &lt;code&gt;stored_key&lt;/code&gt;, a trailing space, and anything an attacker injects.&lt;/p&gt;

&lt;p&gt;The only caller reads &lt;code&gt;process.env.STORAGE_MODE&lt;/code&gt; and nothing else. No request, header, cookie, or query parameter appears anywhere in that path. The reader also throws if it ever runs with &lt;code&gt;window&lt;/code&gt; defined, so a client component cannot pull it into the browser bundle.&lt;/p&gt;

&lt;h2&gt;
  
  
  How to Build the Agent Loop
&lt;/h2&gt;

&lt;p&gt;The agent proposes actions. The broker performs them. The agent holds no credential.&lt;/p&gt;

&lt;p&gt;The tool list comes from the action registry, so the agent cannot be offered a capability that the registry does not define.&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight typescript"&gt;&lt;code&gt;&lt;span class="k"&gt;export&lt;/span&gt; &lt;span class="kd"&gt;function&lt;/span&gt; &lt;span class="nf"&gt;agentTools&lt;/span&gt;&lt;span class="p"&gt;():&lt;/span&gt; &lt;span class="nx"&gt;AgentTool&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="nf"&gt;listActions&lt;/span&gt;&lt;span class="p"&gt;().&lt;/span&gt;&lt;span class="nf"&gt;map&lt;/span&gt;&lt;span class="p"&gt;((&lt;/span&gt;&lt;span class="nx"&gt;action&lt;/span&gt;&lt;span class="p"&gt;)&lt;/span&gt; &lt;span class="o"&gt;=&amp;gt;&lt;/span&gt; &lt;span class="p"&gt;({&lt;/span&gt;
    &lt;span class="na"&gt;type&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="dl"&gt;"&lt;/span&gt;&lt;span class="s2"&gt;function&lt;/span&gt;&lt;span class="dl"&gt;"&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt;
    &lt;span class="na"&gt;name&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="nx"&gt;action&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nx"&gt;id&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt;
    &lt;span class="na"&gt;description&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="nf"&gt;describe&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="nx"&gt;action&lt;/span&gt;&lt;span class="p"&gt;),&lt;/span&gt;
    &lt;span class="na"&gt;parameters&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="nf"&gt;toJsonSchema&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="nx"&gt;action&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nx"&gt;input&lt;/span&gt;&lt;span class="p"&gt;),&lt;/span&gt;
    &lt;span class="na"&gt;strict&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="kc"&gt;false&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;The loop reads the model id from configuration. It never appears as a literal in the source.&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight typescript"&gt;&lt;code&gt;&lt;span class="kd"&gt;const&lt;/span&gt; &lt;span class="nx"&gt;response&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="k"&gt;await&lt;/span&gt; &lt;span class="nx"&gt;openai&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nx"&gt;responses&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nf"&gt;create&lt;/span&gt;&lt;span class="p"&gt;({&lt;/span&gt;
  &lt;span class="nx"&gt;model&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt;
  &lt;span class="na"&gt;input&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="nx"&gt;conversation&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt;
  &lt;span class="nx"&gt;tools&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt;
&lt;span class="p"&gt;});&lt;/span&gt;

&lt;span class="kd"&gt;const&lt;/span&gt; &lt;span class="nx"&gt;requested&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="nx"&gt;response&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nx"&gt;output&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nf"&gt;filter&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;
  &lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="nx"&gt;item&lt;/span&gt;&lt;span class="p"&gt;):&lt;/span&gt; &lt;span class="nx"&gt;item&lt;/span&gt; &lt;span class="k"&gt;is&lt;/span&gt; &lt;span class="nb"&gt;Extract&lt;/span&gt;&lt;span class="o"&gt;&amp;lt;&lt;/span&gt;&lt;span class="k"&gt;typeof&lt;/span&gt; &lt;span class="nx"&gt;item&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="p"&gt;{&lt;/span&gt; &lt;span class="na"&gt;type&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="dl"&gt;"&lt;/span&gt;&lt;span class="s2"&gt;function_call&lt;/span&gt;&lt;span class="dl"&gt;"&lt;/span&gt; &lt;span class="p"&gt;}&lt;/span&gt;&lt;span class="o"&gt;&amp;gt;&lt;/span&gt; &lt;span class="o"&gt;=&amp;gt;&lt;/span&gt;
    &lt;span class="nx"&gt;item&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="kd"&gt;type&lt;/span&gt; &lt;span class="o"&gt;===&lt;/span&gt; &lt;span class="dl"&gt;"&lt;/span&gt;&lt;span class="s2"&gt;function_call&lt;/span&gt;&lt;span class="dl"&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;for &lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="kd"&gt;const&lt;/span&gt; &lt;span class="nx"&gt;toolCall&lt;/span&gt; &lt;span class="k"&gt;of&lt;/span&gt; &lt;span class="nx"&gt;requested&lt;/span&gt;&lt;span class="p"&gt;)&lt;/span&gt; &lt;span class="p"&gt;{&lt;/span&gt;
  &lt;span class="k"&gt;await&lt;/span&gt; &lt;span class="nf"&gt;emit&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="dl"&gt;"&lt;/span&gt;&lt;span class="s2"&gt;agent.tool_requested&lt;/span&gt;&lt;span class="dl"&gt;"&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="s2"&gt;`Requested &lt;/span&gt;&lt;span class="p"&gt;${&lt;/span&gt;&lt;span class="nx"&gt;toolCall&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nx"&gt;name&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="c1"&gt;// The one and only path to GitHub.&lt;/span&gt;
  &lt;span class="kd"&gt;const&lt;/span&gt; &lt;span class="nx"&gt;outcome&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="k"&gt;await&lt;/span&gt; &lt;span class="nf"&gt;brokerAction&lt;/span&gt;&lt;span class="p"&gt;({&lt;/span&gt;
    &lt;span class="na"&gt;actionId&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="nx"&gt;toolCall&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nx"&gt;name&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt;
    &lt;span class="nx"&gt;input&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt;
    &lt;span class="na"&gt;kindeUserId&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="nx"&gt;request&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nx"&gt;kindeUserId&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt;
    &lt;span class="nx"&gt;correlationId&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt;
    &lt;span class="nx"&gt;runId&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt;
  &lt;span class="p"&gt;});&lt;/span&gt;
  &lt;span class="c1"&gt;// ...&lt;/span&gt;
&lt;span class="p"&gt;}&lt;/span&gt;
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;I use the Responses API rather than chat completions for a measured reason. The model family I tested rejects function tools on &lt;code&gt;/v1/chat/completions&lt;/code&gt; unless reasoning is switched off:&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight console"&gt;&lt;code&gt;&lt;span class="go"&gt;chat  effort=none     OK tool_calls=1
chat  effort=low      ERR 400 Function tools with reasoning_effort are not supported
responses api         OK  types=function_call
&lt;/span&gt;&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;Staying on chat completions would have meant disabling the model's reasoning to get tools. The Responses API carries both.&lt;/p&gt;

&lt;p&gt;When the broker refuses for lack of a credential, the agent stops. It does not retry, and it does not reach for another tool.&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight typescript"&gt;&lt;code&gt;&lt;span class="c1"&gt;// Fail closed. A credential refusal will not resolve itself, so the&lt;/span&gt;
&lt;span class="c1"&gt;// run stops here rather than looping or reaching for another route.&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;outcome&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nx"&gt;status&lt;/span&gt; &lt;span class="o"&gt;===&lt;/span&gt; &lt;span class="dl"&gt;"&lt;/span&gt;&lt;span class="s2"&gt;refused&lt;/span&gt;&lt;span class="dl"&gt;"&lt;/span&gt; &lt;span class="o"&gt;&amp;amp;&amp;amp;&lt;/span&gt; &lt;span class="nx"&gt;outcome&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nx"&gt;refusal&lt;/span&gt; &lt;span class="o"&gt;===&lt;/span&gt; &lt;span class="dl"&gt;"&lt;/span&gt;&lt;span class="s2"&gt;credential&lt;/span&gt;&lt;span class="dl"&gt;"&lt;/span&gt;&lt;span class="p"&gt;)&lt;/span&gt; &lt;span class="p"&gt;{&lt;/span&gt;
  &lt;span class="nx"&gt;halt&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="kc"&gt;true&lt;/span&gt;&lt;span class="p"&gt;;&lt;/span&gt;
  &lt;span class="nx"&gt;status&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="dl"&gt;"&lt;/span&gt;&lt;span class="s2"&gt;refused&lt;/span&gt;&lt;span class="dl"&gt;"&lt;/span&gt;&lt;span class="p"&gt;;&lt;/span&gt;
  &lt;span class="nx"&gt;finalMessage&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="s2"&gt;`Stopped: &lt;/span&gt;&lt;span class="p"&gt;${&lt;/span&gt;&lt;span class="nx"&gt;outcome&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nx"&gt;reason&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;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;The broker returns a typed refusal kind so this check is structural, not a string match.&lt;/p&gt;

&lt;div class="table-wrapper-paragraph"&gt;&lt;table&gt;
&lt;thead&gt;
&lt;tr&gt;
&lt;th&gt;Refusal kind&lt;/th&gt;
&lt;th&gt;Meaning&lt;/th&gt;
&lt;th&gt;Caller behaviour&lt;/th&gt;
&lt;/tr&gt;
&lt;/thead&gt;
&lt;tbody&gt;
&lt;tr&gt;
&lt;td&gt;&lt;code&gt;credential&lt;/code&gt;&lt;/td&gt;
&lt;td&gt;No token now, and none coming&lt;/td&gt;
&lt;td&gt;Stop&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;&lt;code&gt;input&lt;/code&gt;&lt;/td&gt;
&lt;td&gt;The arguments failed validation&lt;/td&gt;
&lt;td&gt;Correct and retry&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;&lt;code&gt;unknown-action&lt;/code&gt;&lt;/td&gt;
&lt;td&gt;The name is not in the registry&lt;/td&gt;
&lt;td&gt;Stop&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;&lt;code&gt;no-user&lt;/code&gt;&lt;/td&gt;
&lt;td&gt;No such user&lt;/td&gt;
&lt;td&gt;Stop&lt;/td&gt;
&lt;/tr&gt;
&lt;/tbody&gt;
&lt;/table&gt;&lt;/div&gt;

&lt;p&gt;I drew that line on purpose. An &lt;code&gt;input&lt;/code&gt; refusal is the model's own mistake, and letting it fix the arguments is self correction rather than a blind retry of a denied action.&lt;/p&gt;

&lt;p&gt;Four structural tests hold the agent honest. The agent module never imports the GitHub client, never names the GitHub API host, never reads a credential from the environment, and contains no storage mode branch at all. A test walks the source tree and fails the build if any of these change.&lt;/p&gt;

&lt;h2&gt;
  
  
  Two Revocation Levers, and One Is a Decoy
&lt;/h2&gt;

&lt;p&gt;The Kinde Management API exposes two things that sound like a kill switch. Only one is.&lt;/p&gt;

&lt;p&gt;I tested both against the live service.&lt;/p&gt;

&lt;p&gt;&lt;strong&gt;Lever A: &lt;code&gt;DELETE /api/v1/users/{user_id}/sessions&lt;/code&gt;.&lt;/strong&gt;&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight console"&gt;&lt;code&gt;&lt;span class="go"&gt;DELETE sessions: HTTP 200 in 420ms
{"code":"USER_SESSIONS_INVALIDATED","message":"User sessions successfully invalidated"}
&lt;/span&gt;&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;Then I polled the token endpoint every two seconds:&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight console"&gt;&lt;code&gt;&lt;span class="go"&gt;  +  2303ms  STILL ISSUING  HTTP 200  (fingerprint 7ea1fb9d9fa2)
&lt;/span&gt;&lt;span class="c"&gt;  ...
&lt;/span&gt;&lt;span class="go"&gt;  + 60011ms  STILL ISSUING  HTTP 200  (fingerprint 7ea1fb9d9fa2)

Kinde was still issuing tokens 60000ms after revocation, across 23 attempts.
&lt;/span&gt;&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;Twenty three consecutive requests over a full minute. Every one returned HTTP 200. Every one returned the same token.&lt;/p&gt;

&lt;p&gt;The endpoint reports success and stops nothing. The reason shows in the data. Before the test, &lt;code&gt;GET /users/{id}/sessions&lt;/code&gt; returned &lt;code&gt;"sessions": []&lt;/code&gt;, an empty list, while the connection was live and issuing tokens. A connected app session is not a user session. They are separate objects, and the user sessions endpoint does not reach the connected app.&lt;/p&gt;

&lt;p&gt;If you build a kill switch on this endpoint, it returns HTTP 200, it logs success, and it cuts off nothing.&lt;/p&gt;

&lt;p&gt;&lt;strong&gt;Lever B: &lt;code&gt;POST /api/v1/connected_apps/revoke&lt;/code&gt;.&lt;/strong&gt;&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight console"&gt;&lt;code&gt;&lt;span class="go"&gt;POST /api/v1/connected_apps/revoke   HTTP 200 in 151ms
{"message":"token revoke successful","code":"REVOKE_SUCCESSFUL"}

  +     0ms  REFUSED  HTTP 400  INVALID_SESSION: Error encountered while
                                retrieving tokens for connected app
&lt;/span&gt;&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;The very next request failed. Not eventually. The first call after the revoke returned HTTP 400 with &lt;code&gt;INVALID_SESSION&lt;/code&gt;.&lt;/p&gt;

&lt;p&gt;This is the kill switch. I pinned it in code and in a test.&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight typescript"&gt;&lt;code&gt;&lt;span class="k"&gt;export&lt;/span&gt; &lt;span class="k"&gt;async&lt;/span&gt; &lt;span class="kd"&gt;function&lt;/span&gt; &lt;span class="nf"&gt;revokeConnection&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;
  &lt;span class="nx"&gt;request&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="nx"&gt;RevocationRequest&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt;
&lt;span class="p"&gt;):&lt;/span&gt; &lt;span class="nb"&gt;Promise&lt;/span&gt;&lt;span class="o"&gt;&amp;lt;&lt;/span&gt;&lt;span class="nx"&gt;RevocationResult&lt;/span&gt;&lt;span class="o"&gt;&amp;gt;&lt;/span&gt; &lt;span class="p"&gt;{&lt;/span&gt;
  &lt;span class="c1"&gt;// ...&lt;/span&gt;
  &lt;span class="kd"&gt;const&lt;/span&gt; &lt;span class="nx"&gt;result&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="k"&gt;await&lt;/span&gt; &lt;span class="nf"&gt;revokeConnectedAppSession&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="nx"&gt;connection&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nx"&gt;kindeSessionId&lt;/span&gt;&lt;span class="p"&gt;);&lt;/span&gt;

  &lt;span class="k"&gt;if &lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="o"&gt;!&lt;/span&gt;&lt;span class="nx"&gt;result&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nx"&gt;ok&lt;/span&gt;&lt;span class="p"&gt;)&lt;/span&gt; &lt;span class="p"&gt;{&lt;/span&gt;
    &lt;span class="kd"&gt;const&lt;/span&gt; &lt;span class="nx"&gt;reason&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="s2"&gt;`Kinde refused the revocation (HTTP &lt;/span&gt;&lt;span class="p"&gt;${&lt;/span&gt;&lt;span class="nx"&gt;result&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nx"&gt;status&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="k"&gt;await&lt;/span&gt; &lt;span class="nf"&gt;audit&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="dl"&gt;"&lt;/span&gt;&lt;span class="s2"&gt;failed&lt;/span&gt;&lt;span class="dl"&gt;"&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="nx"&gt;reason&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="nx"&gt;user&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nx"&gt;_id&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="na"&gt;status&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="dl"&gt;"&lt;/span&gt;&lt;span class="s2"&gt;failed&lt;/span&gt;&lt;span class="dl"&gt;"&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="nx"&gt;reason&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="na"&gt;httpStatus&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="nx"&gt;result&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nx"&gt;status&lt;/span&gt; &lt;span class="p"&gt;};&lt;/span&gt;
  &lt;span class="p"&gt;}&lt;/span&gt;

  &lt;span class="c1"&gt;// Record the revocation locally too. The broker refuses on its own&lt;/span&gt;
  &lt;span class="c1"&gt;// authority once the connection reads `revoked`, rather than depending on&lt;/span&gt;
  &lt;span class="c1"&gt;// Kinde's refusal alone.&lt;/span&gt;
  &lt;span class="k"&gt;await&lt;/span&gt; &lt;span class="nx"&gt;convex&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nf"&gt;mutation&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="nx"&gt;api&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nx"&gt;gateway&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nx"&gt;markRevoked&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="p"&gt;{&lt;/span&gt; &lt;span class="nx"&gt;secret&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="na"&gt;userId&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="nx"&gt;user&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nx"&gt;_id&lt;/span&gt; &lt;span class="p"&gt;});&lt;/span&gt;
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;The test asserts that the revoke module calls &lt;code&gt;revokeConnectedAppSession&lt;/code&gt;, never mentions &lt;code&gt;deleteUserSessions&lt;/code&gt;, and that the only file in the whole tree that calls the user sessions endpoint is the probe script where I measured it. A later edit that simplifies onto the decoy fails the build.&lt;/p&gt;

&lt;h2&gt;
  
  
  The Central Finding: Revocation Reaches the Connection, Not the Token
&lt;/h2&gt;

&lt;p&gt;Now the result that matters most, and the one that weakens the easy version of this story.&lt;/p&gt;

&lt;p&gt;After &lt;code&gt;connected_apps/revoke&lt;/code&gt; succeeds and Kinde refuses to broker, does a token that Kinde already handed out still work at GitHub?&lt;/p&gt;

&lt;p&gt;I measured it. I brokered a token, held it, revoked the connection, confirmed Kinde refused, then kept calling GitHub with the token I already had.&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight console"&gt;&lt;code&gt;&lt;span class="go"&gt;Step 1  broker a token and hold it        gho_ fingerprint ffda970320cd
Step 2  held token works before revoke    GET /user HTTP 200
Step 3  POST connected_apps/revoke        HTTP 200 in 177ms
Step 4  can Kinde still broker a token?   REFUSED HTTP 400 INVALID_SESSION
Step 5  does the HELD token still work?
          +   397ms  GET /user HTTP 200  STILL VALID
          +  6115ms  GET /user HTTP 200  STILL VALID
          + 13055ms  GET /user HTTP 200  STILL VALID
          + 19051ms  GET /user HTTP 200  STILL VALID
          + 30235ms  GET /user HTTP 200  STILL VALID
          + 37646ms  GET /user HTTP 200  STILL VALID
&lt;/span&gt;&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;Kinde refused to broker in the same second that GitHub was still serving the token it had already issued. Six calls over 37.6 seconds. Every one returned HTTP 200.&lt;/p&gt;

&lt;p&gt;&lt;strong&gt;Revocation at Kinde does not reach into GitHub.&lt;/strong&gt; It stops Kinde from brokering and from refreshing. It does not cancel an outstanding token.&lt;/p&gt;

&lt;p&gt;So the instant cutoff in &lt;code&gt;connected-app&lt;/code&gt; mode is real, but the reason is not the one you might assume. The cutoff is instant &lt;strong&gt;because the broker keeps nothing between actions&lt;/strong&gt;. The next action must ask Kinde. Kinde refuses. The action stops. There is no credential left inside the app for anything to keep using.&lt;/p&gt;

&lt;p&gt;This has a direct consequence for your code. Do not cache the brokered token, not even for a few seconds. A cache is a window of access that survives the kill switch. In this design, fetch per action is a security boundary rather than a performance choice.&lt;/p&gt;

&lt;p&gt;&lt;a href="https://media2.dev.to/dynamic/image/width=800%2Cheight=%2Cfit=scale-down%2Cgravity=auto%2Cformat=auto/https%3A%2F%2Fdev-to-uploads.s3.us-east-2.amazonaws.com%2Fuploads%2Farticles%2Filru4unhrr974km5rrju.png" class="article-body-image-wrapper"&gt;&lt;img src="https://media2.dev.to/dynamic/image/width=800%2Cheight=%2Cfit=scale-down%2Cgravity=auto%2Cformat=auto/https%3A%2F%2Fdev-to-uploads.s3.us-east-2.amazonaws.com%2Fuploads%2Farticles%2Filru4unhrr974km5rrju.png" alt="Every path an action can take through the broker. An unregistered name never reaches a credential. A missing or refused token ends in token.refused. A GitHub error ends in a recorded failure. A success ends with the token going out of scope. Each ending writes an audit row, so no action leaves the broker without a record." width="800" height="1390"&gt;&lt;/a&gt;&lt;/p&gt;

&lt;p&gt;&lt;em&gt;One revocation, and what each mode does next. Kinde stops brokering at once, so the &lt;code&gt;connected-app&lt;/code&gt; broker gets &lt;code&gt;INVALID_SESSION&lt;/code&gt; on its next request and the agent stops. The &lt;code&gt;stored-key&lt;/code&gt; app never asks Kinde, so it keeps acting and its comment lands after the revocation. GitHub is the third party in this picture, and it honours the token it already issued until that token expires.&lt;/em&gt;&lt;/p&gt;

&lt;h2&gt;
  
  
  The Two Modes, Side by Side
&lt;/h2&gt;

&lt;p&gt;I ran both modes against the same revocation and recorded what happened.&lt;/p&gt;

&lt;p&gt;The revocation landed at 18:41:52 UTC. Here is the audit log across both modes, ordered by time.&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight console"&gt;&lt;code&gt;&lt;span class="go"&gt;time      mode           event              outcome   detail
&lt;/span&gt;&lt;span class="gp"&gt;18:41:31  connected-app  action.invoked     allowed   Commented on issue #&lt;/span&gt;3 as sholajegede.
&lt;span class="go"&gt;18:41:52  connected-app  connection.revoked allowed   Connection revoked at Kinde.
18:42:20  connected-app  token.refused      refused   The GitHub connection is revoked.
18:45:52  connected-app  token.refused      refused   INVALID_SESSION: ...
18:46:17  stored-key     token.brokered     allowed   Used the token the app holds. Kinde was not consulted.
&lt;/span&gt;&lt;span class="gp"&gt;18:46:32  stored-key     action.invoked     allowed   Commented on issue #&lt;/span&gt;2 as sholajegede.
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;The &lt;code&gt;stored-key&lt;/code&gt; comment is real. It has a GitHub comment id and a &lt;code&gt;created_at&lt;/code&gt; of &lt;code&gt;2026-08-14T18:46:31Z&lt;/code&gt;, which is &lt;strong&gt;4 minutes and 39 seconds after the revocation&lt;/strong&gt;. An agent that the operator believed they had cut off carried on writing to the repository.&lt;/p&gt;

&lt;p&gt;Every &lt;code&gt;stored-key&lt;/code&gt; audit row says why, in the row itself: &lt;em&gt;"Used the token the app holds. Kinde was not consulted."&lt;/em&gt;&lt;/p&gt;

&lt;p&gt;The &lt;code&gt;connected-app&lt;/code&gt; side shows two independent layers. The first refusal came from the local flag, because the broker marks the connection revoked and then refuses on its own authority. To check that Kinde also refuses, I forced the local flag back to &lt;code&gt;linked&lt;/code&gt; while the Kinde session stayed revoked, and ran again:&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight console"&gt;&lt;code&gt;&lt;span class="go"&gt;3  broker.refused   INVALID_SESSION: Error encountered while retrieving tokens for connected app
&lt;/span&gt;&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;&lt;code&gt;INVALID_SESSION&lt;/code&gt; straight from Kinde, with the app's own bookkeeping claiming the connection was fine. Either layer stops the agent on its own. The cutoff does not depend on your local state being correct.&lt;/p&gt;

&lt;p&gt;&lt;a href="https://media2.dev.to/dynamic/image/width=800%2Cheight=%2Cfit=scale-down%2Cgravity=auto%2Cformat=auto/https%3A%2F%2Fdev-to-uploads.s3.us-east-2.amazonaws.com%2Fuploads%2Farticles%2Fldr6mpdggk5p19hdtapv.png" class="article-body-image-wrapper"&gt;&lt;img src="https://media2.dev.to/dynamic/image/width=800%2Cheight=%2Cfit=scale-down%2Cgravity=auto%2Cformat=auto/https%3A%2F%2Fdev-to-uploads.s3.us-east-2.amazonaws.com%2Fuploads%2Farticles%2Fldr6mpdggk5p19hdtapv.png" alt="The console after the operator clicks Revoke connection. A green notice reads that the connection was revoked, that Kinde will broker no further token, that the next action is refused, and that the operator is still signed in. The connection status badge reads revoked. The counts panel shows zero GitHub tokens that the app keeps and zero actions after revocation. The header still shows the signed in operator and a Sign out button." width="799" height="404"&gt;&lt;/a&gt;&lt;/p&gt;

&lt;p&gt;&lt;em&gt;After the revoke. The GitHub connection is cut. The operator stays signed in to the app.&lt;/em&gt;&lt;/p&gt;

&lt;p&gt;That last point matters and I tested it live. Revoking the GitHub connection does not end the app session. I clicked Revoke, then reloaded the page in the browser. The header still read the operator's name. The connection read &lt;code&gt;revoked&lt;/code&gt;. The user keeps their session with your product and loses only the third party connection they chose to cut.&lt;/p&gt;

&lt;p&gt;Ask the agent for something after the revoke, and the run stops on the first tool call.&lt;/p&gt;

&lt;p&gt;&lt;a href="https://media2.dev.to/dynamic/image/width=800%2Cheight=%2Cfit=scale-down%2Cgravity=auto%2Cformat=auto/https%3A%2F%2Fdev-to-uploads.s3.us-east-2.amazonaws.com%2Fuploads%2Farticles%2Fcuv15qrlva3o6p8el7jf.png" class="article-body-image-wrapper"&gt;&lt;img src="https://media2.dev.to/dynamic/image/width=800%2Cheight=%2Cfit=scale-down%2Cgravity=auto%2Cformat=auto/https%3A%2F%2Fdev-to-uploads.s3.us-east-2.amazonaws.com%2Fuploads%2Farticles%2Fcuv15qrlva3o6p8el7jf.png" alt="The live timeline after a revoke. The run status reads refused. The rows read task received, tool requested read_issues, broker refused with the message that the GitHub connection is revoked, halted with the message that the broker refused to supply a credential and the agent stopped instead of retrying, and finished. Below, the records table shows a token.refused row with outcome refused and a connection.revoked row with outcome allowed, each with its correlation id." width="799" height="430"&gt;&lt;/a&gt;&lt;/p&gt;

&lt;p&gt;&lt;em&gt;The agent asks for one tool, the broker refuses, and the run halts. No retry, and no second route.&lt;/em&gt;&lt;/p&gt;

&lt;h2&gt;
  
  
  How to Keep Least Privilege
&lt;/h2&gt;

&lt;p&gt;The registry defines four actions and nothing else. The agent cannot name its way to a capability that does not exist, because &lt;code&gt;getAction()&lt;/code&gt; throws on anything unregistered.&lt;/p&gt;

&lt;div class="table-wrapper-paragraph"&gt;&lt;table&gt;
&lt;thead&gt;
&lt;tr&gt;
&lt;th&gt;Action&lt;/th&gt;
&lt;th&gt;Effect&lt;/th&gt;
&lt;th&gt;Acts as the user&lt;/th&gt;
&lt;th&gt;Scope&lt;/th&gt;
&lt;/tr&gt;
&lt;/thead&gt;
&lt;tbody&gt;
&lt;tr&gt;
&lt;td&gt;&lt;code&gt;read_issues&lt;/code&gt;&lt;/td&gt;
&lt;td&gt;Reads&lt;/td&gt;
&lt;td&gt;No&lt;/td&gt;
&lt;td&gt;&lt;code&gt;public_repo&lt;/code&gt;&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;&lt;code&gt;read_issue&lt;/code&gt;&lt;/td&gt;
&lt;td&gt;Reads&lt;/td&gt;
&lt;td&gt;No&lt;/td&gt;
&lt;td&gt;&lt;code&gt;public_repo&lt;/code&gt;&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;&lt;code&gt;comment_issue&lt;/code&gt;&lt;/td&gt;
&lt;td&gt;Writes&lt;/td&gt;
&lt;td&gt;Yes&lt;/td&gt;
&lt;td&gt;&lt;code&gt;public_repo&lt;/code&gt;&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;&lt;code&gt;open_pr&lt;/code&gt;&lt;/td&gt;
&lt;td&gt;Writes&lt;/td&gt;
&lt;td&gt;Yes&lt;/td&gt;
&lt;td&gt;&lt;code&gt;public_repo&lt;/code&gt;&lt;/td&gt;
&lt;/tr&gt;
&lt;/tbody&gt;
&lt;/table&gt;&lt;/div&gt;

&lt;p&gt;I first wrote the registry with &lt;code&gt;public_repo&lt;/code&gt; and &lt;code&gt;read:user&lt;/code&gt;, so the audit trail could record who acted. Then I hit a real constraint. &lt;strong&gt;Kinde's GitHub scope picker does not offer &lt;code&gt;read:user&lt;/code&gt;.&lt;/strong&gt; The connection grants &lt;code&gt;public_repo&lt;/code&gt; and nothing else.&lt;/p&gt;

&lt;p&gt;A registry that claims a scope the connection cannot hold is simply wrong, so I dropped it. The identity still gets recorded, because GitHub echoes the acting account back in the responses the actions already make.&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight typescript"&gt;&lt;code&gt;&lt;span class="k"&gt;export&lt;/span&gt; &lt;span class="kd"&gt;function&lt;/span&gt; &lt;span class="nf"&gt;actingUserFrom&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="nx"&gt;payload&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="nx"&gt;unknown&lt;/span&gt;&lt;span class="p"&gt;):&lt;/span&gt; &lt;span class="nx"&gt;ActingUser&lt;/span&gt; &lt;span class="o"&gt;|&lt;/span&gt; &lt;span class="kc"&gt;null&lt;/span&gt; &lt;span class="p"&gt;{&lt;/span&gt;
  &lt;span class="k"&gt;if &lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="o"&gt;!&lt;/span&gt;&lt;span class="nx"&gt;payload&lt;/span&gt; &lt;span class="o"&gt;||&lt;/span&gt; &lt;span class="k"&gt;typeof&lt;/span&gt; &lt;span class="nx"&gt;payload&lt;/span&gt; &lt;span class="o"&gt;!==&lt;/span&gt; &lt;span class="dl"&gt;"&lt;/span&gt;&lt;span class="s2"&gt;object&lt;/span&gt;&lt;span class="dl"&gt;"&lt;/span&gt;&lt;span class="p"&gt;)&lt;/span&gt; &lt;span class="k"&gt;return&lt;/span&gt; &lt;span class="kc"&gt;null&lt;/span&gt;&lt;span class="p"&gt;;&lt;/span&gt;
  &lt;span class="kd"&gt;const&lt;/span&gt; &lt;span class="nx"&gt;body&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="nx"&gt;payload&lt;/span&gt; &lt;span class="k"&gt;as&lt;/span&gt; &lt;span class="nx"&gt;MaybeUser&lt;/span&gt;&lt;span class="p"&gt;;&lt;/span&gt;

  &lt;span class="c1"&gt;// A nested `user` is the shape returned by comments and pull requests.&lt;/span&gt;
  &lt;span class="kd"&gt;const&lt;/span&gt; &lt;span class="nx"&gt;candidate&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt;
    &lt;span class="k"&gt;typeof&lt;/span&gt; &lt;span class="nx"&gt;body&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nx"&gt;user&lt;/span&gt;&lt;span class="p"&gt;?.&lt;/span&gt;&lt;span class="nx"&gt;login&lt;/span&gt; &lt;span class="o"&gt;===&lt;/span&gt; &lt;span class="dl"&gt;"&lt;/span&gt;&lt;span class="s2"&gt;string&lt;/span&gt;&lt;span class="dl"&gt;"&lt;/span&gt;
      &lt;span class="p"&gt;?&lt;/span&gt; &lt;span class="nx"&gt;body&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nx"&gt;user&lt;/span&gt;
      &lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="k"&gt;typeof&lt;/span&gt; &lt;span class="nx"&gt;body&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nx"&gt;login&lt;/span&gt; &lt;span class="o"&gt;===&lt;/span&gt; &lt;span class="dl"&gt;"&lt;/span&gt;&lt;span class="s2"&gt;string&lt;/span&gt;&lt;span class="dl"&gt;"&lt;/span&gt;
        &lt;span class="p"&gt;?&lt;/span&gt; &lt;span class="nx"&gt;body&lt;/span&gt;
        &lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="kc"&gt;null&lt;/span&gt;&lt;span class="p"&gt;;&lt;/span&gt;

  &lt;span class="k"&gt;if &lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="o"&gt;!&lt;/span&gt;&lt;span class="nx"&gt;candidate&lt;/span&gt; &lt;span class="o"&gt;||&lt;/span&gt; &lt;span class="k"&gt;typeof&lt;/span&gt; &lt;span class="nx"&gt;candidate&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nx"&gt;login&lt;/span&gt; &lt;span class="o"&gt;!==&lt;/span&gt; &lt;span class="dl"&gt;"&lt;/span&gt;&lt;span class="s2"&gt;string&lt;/span&gt;&lt;span class="dl"&gt;"&lt;/span&gt;&lt;span class="p"&gt;)&lt;/span&gt; &lt;span class="k"&gt;return&lt;/span&gt; &lt;span class="kc"&gt;null&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="na"&gt;login&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="nx"&gt;candidate&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nx"&gt;login&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt;
    &lt;span class="na"&gt;id&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="k"&gt;typeof&lt;/span&gt; &lt;span class="nx"&gt;candidate&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nx"&gt;id&lt;/span&gt; &lt;span class="o"&gt;===&lt;/span&gt; &lt;span class="dl"&gt;"&lt;/span&gt;&lt;span class="s2"&gt;number&lt;/span&gt;&lt;span class="dl"&gt;"&lt;/span&gt; &lt;span class="p"&gt;?&lt;/span&gt; &lt;span class="nx"&gt;candidate&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nx"&gt;id&lt;/span&gt; &lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="kc"&gt;null&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;A comment response carries &lt;code&gt;{ user: { login } }&lt;/code&gt;. &lt;code&gt;GET /user&lt;/code&gt; needs no scope at all. When a response carries no identity, the function returns null and the app audits the action without a login. The app does not widen the connection to get one.&lt;/p&gt;

&lt;p&gt;The broker also compares the scopes GitHub reports against what the registry claims, and records both directions. In &lt;code&gt;connected-app&lt;/code&gt; mode the audit row reads:&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight plaintext"&gt;&lt;code&gt;Read 2 open issue(s) in sholajegede/connected-apps-github-sandbox.
Least privilege holds: the credential carries exactly what the action needs.
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;In &lt;code&gt;stored-key&lt;/code&gt; mode the same code reads:&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight plaintext"&gt;&lt;code&gt;Over-privileged: the credential also carries gist, read:org, workflow,
which this action does not need.
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;That is the second failure of the stored key, visible in the audit trail. The stored key is not only impossible to revoke centrally. It is also far broader than the work needs, because a token that a developer already had tends to be the token that gets used.&lt;/p&gt;

&lt;h2&gt;
  
  
  What the Operator Controls
&lt;/h2&gt;

&lt;p&gt;The demo gives the user the controls rather than a video of somebody else using them.&lt;/p&gt;

&lt;p&gt;The operator signs in with their own session. They connect GitHub themselves. They write the task in free text, so nothing is a canned script. They watch each step arrive in a live timeline, and they cut the connection when they choose.&lt;/p&gt;

&lt;p&gt;The console shows five counts, and each one answers a question a reader will ask.&lt;/p&gt;

&lt;div class="table-wrapper-paragraph"&gt;&lt;table&gt;
&lt;thead&gt;
&lt;tr&gt;
&lt;th&gt;Count&lt;/th&gt;
&lt;th&gt;What it answers&lt;/th&gt;
&lt;/tr&gt;
&lt;/thead&gt;
&lt;tbody&gt;
&lt;tr&gt;
&lt;td&gt;Tokens supplied&lt;/td&gt;
&lt;td&gt;How often the broker asked Kinde&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;GitHub tokens that the app keeps&lt;/td&gt;
&lt;td&gt;Zero in &lt;code&gt;connected-app&lt;/code&gt;, one in &lt;code&gt;stored-key&lt;/code&gt;
&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;Actions done on GitHub&lt;/td&gt;
&lt;td&gt;What really happened in the repository&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;Actions refused&lt;/td&gt;
&lt;td&gt;How often the broker said no&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;Actions after revocation&lt;/td&gt;
&lt;td&gt;Zero in &lt;code&gt;connected-app&lt;/code&gt;, above zero in &lt;code&gt;stored-key&lt;/code&gt;
&lt;/td&gt;
&lt;/tr&gt;
&lt;/tbody&gt;
&lt;/table&gt;&lt;/div&gt;

&lt;p&gt;The second count is not derived from the data. It is a property of the deployment, decided on the server and passed down for display. The console shows it and can never set it. The same holds for the storage mode badge in the header.&lt;/p&gt;

&lt;p&gt;The last count is the one to watch during a demo. It shows a dash until the operator revokes. After that it turns green at zero in &lt;code&gt;connected-app&lt;/code&gt;, and red at a real number in &lt;code&gt;stored-key&lt;/code&gt;.&lt;/p&gt;

&lt;h2&gt;
  
  
  How to Harden It for Production
&lt;/h2&gt;

&lt;p&gt;A demo becomes trustworthy when the failure paths behave.&lt;/p&gt;

&lt;p&gt;&lt;strong&gt;Give every outbound call a deadline.&lt;/strong&gt; A hang is an outage from the caller's point of view, and an action that waits forever can never fail closed. I set 8 seconds on Kinde and 12 seconds on GitHub. I tested the Kinde deadline by pointing the issuer URL at an unroutable address:&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight console"&gt;&lt;code&gt;&lt;span class="go"&gt;outcome        refused
reason         Kinde did not answer within 8000ms.
audit:  token.refused  refused  connected-app  Kinde did not answer within 8000ms.
&lt;/span&gt;&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;Before I added this, that path threw past the broker with &lt;strong&gt;no audit row at all&lt;/strong&gt;. The credential step sat outside any try block. The fail closed claim was false for exactly the case that needed it most. Finding this is the reason to test outages rather than reason about them.&lt;/p&gt;

&lt;p&gt;&lt;strong&gt;Refuse an action you cannot record.&lt;/strong&gt; The broker writes the &lt;code&gt;token.brokered&lt;/code&gt; row before it calls GitHub. If that write fails, the action does not happen.&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight typescript"&gt;&lt;code&gt;&lt;span class="k"&gt;if &lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="o"&gt;!&lt;/span&gt;&lt;span class="nx"&gt;recorded&lt;/span&gt;&lt;span class="p"&gt;)&lt;/span&gt; &lt;span class="p"&gt;{&lt;/span&gt;
  &lt;span class="kd"&gt;const&lt;/span&gt; &lt;span class="nx"&gt;reason&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt;
    &lt;span class="dl"&gt;"&lt;/span&gt;&lt;span class="s2"&gt;The audit trail is unavailable. Refusing to act rather than act without a record.&lt;/span&gt;&lt;span class="dl"&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="na"&gt;status&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="dl"&gt;"&lt;/span&gt;&lt;span class="s2"&gt;refused&lt;/span&gt;&lt;span class="dl"&gt;"&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt;
    &lt;span class="na"&gt;actionId&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="nx"&gt;action&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nx"&gt;id&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt;
    &lt;span class="na"&gt;storageMode&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="nx"&gt;mode&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt;
    &lt;span class="nx"&gt;reason&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt;
    &lt;span class="na"&gt;refusal&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="dl"&gt;"&lt;/span&gt;&lt;span class="s2"&gt;credential&lt;/span&gt;&lt;span class="dl"&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;The row also goes to an append only local queue first, so the attempt stays recoverable rather than lost.&lt;/p&gt;

&lt;p&gt;&lt;strong&gt;Never log a token.&lt;/strong&gt; The broker redacts every GitHub token shape from any text it records, and reports tokens by fingerprint.&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight typescript"&gt;&lt;code&gt;&lt;span class="kd"&gt;const&lt;/span&gt; &lt;span class="nx"&gt;CREDENTIAL_PATTERN&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt;
  &lt;span class="sr"&gt;/gh&lt;/span&gt;&lt;span class="se"&gt;[&lt;/span&gt;&lt;span class="sr"&gt;pousr&lt;/span&gt;&lt;span class="se"&gt;]&lt;/span&gt;&lt;span class="sr"&gt;_&lt;/span&gt;&lt;span class="se"&gt;[&lt;/span&gt;&lt;span class="sr"&gt;A-Za-z0-9&lt;/span&gt;&lt;span class="se"&gt;]{16,}&lt;/span&gt;&lt;span class="sr"&gt;|github_pat_&lt;/span&gt;&lt;span class="se"&gt;[&lt;/span&gt;&lt;span class="sr"&gt;A-Za-z0-9_&lt;/span&gt;&lt;span class="se"&gt;]{16,}&lt;/span&gt;&lt;span class="sr"&gt;/g&lt;/span&gt;&lt;span class="p"&gt;;&lt;/span&gt;

&lt;span class="k"&gt;export&lt;/span&gt; &lt;span class="kd"&gt;function&lt;/span&gt; &lt;span class="nf"&gt;redactCredentials&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="nx"&gt;text&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="kr"&gt;string&lt;/span&gt;&lt;span class="p"&gt;):&lt;/span&gt; &lt;span class="kr"&gt;string&lt;/span&gt; &lt;span class="p"&gt;{&lt;/span&gt;
  &lt;span class="k"&gt;return&lt;/span&gt; &lt;span class="nx"&gt;text&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nf"&gt;replace&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="nx"&gt;CREDENTIAL_PATTERN&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="dl"&gt;"&lt;/span&gt;&lt;span class="s2"&gt;[redacted]&lt;/span&gt;&lt;span class="dl"&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;A fingerprint is enough to answer real questions without exposing anything. It is a truncated SHA-256 of the token. Two fetches can be compared for equality, and the value itself never appears.&lt;/p&gt;

&lt;p&gt;&lt;strong&gt;Prove the claim at run time.&lt;/strong&gt; A comment in the source is not evidence. This script runs a real agent task, then looks for a credential everywhere the app could have put one: every row of every table, everything the code printed, the agent's own transcript, and the process environment.&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight plaintext"&gt;&lt;code&gt;Step 2 — scan every row of every table in the store
  actions      4 row(s) scanned
  auditLog     56 row(s) scanned
  connections  1 row(s) scanned
  runEvents    78 row(s) scanned
  runs         10 row(s) scanned
  users        1 row(s) scanned

Verdict
CLEAN — connected-app mode.
No GitHub credential is in the store, in the broker's output, or in
the environment.
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;The same script run in &lt;code&gt;stored-key&lt;/code&gt; mode reports &lt;code&gt;FOUND GitHub OAuth token (gho_) in env.GITHUB_STORED_TOKEN&lt;/code&gt;, and stays clean everywhere else. The same assertion, the same code, and a different answer per mode. That contrast is the point.&lt;/p&gt;

&lt;p&gt;One detail makes this proof meaningful. The stored key lives in a separate file that the app loads only when the mode is &lt;code&gt;stored-key&lt;/code&gt;. If it sat in the main environment file, it would be in &lt;code&gt;process.env&lt;/code&gt; on every run, and the environment scan would be worthless.&lt;/p&gt;

&lt;h2&gt;
  
  
  How to Prove the Tests Can Fail
&lt;/h2&gt;

&lt;p&gt;A green test suite means nothing until you have seen it go red.&lt;/p&gt;

&lt;p&gt;The end to end script walks the whole story in one pass: clean slate, a &lt;code&gt;connected-app&lt;/code&gt; run that comments on a real issue, a revoke, a refused run, a &lt;code&gt;stored-key&lt;/code&gt; run after the same revoke, and a reconciliation across both modes. It asserts real outcomes and exits non-zero on any failure.&lt;/p&gt;

&lt;p&gt;Each acting step runs in a child process with its own &lt;code&gt;STORAGE_MODE&lt;/code&gt;. The mode is a deployment decision, and a script that flipped it in its own process would quietly contradict the property it tests.&lt;/p&gt;

&lt;p&gt;Green looks like this:&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight plaintext"&gt;&lt;code&gt;GREEN — every assertion held. 69.3s
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;Then I inverted the load bearing check. Step 4 asserts that the &lt;code&gt;stored-key&lt;/code&gt; agent still acts after the revocation. I changed &lt;code&gt;status === "succeeded"&lt;/code&gt; to &lt;code&gt;status !== "succeeded"&lt;/code&gt;, so the assertion demanded the opposite of the measured truth:&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight console"&gt;&lt;code&gt;&lt;span class="go"&gt;RED — 1 assertion(s) failed. 54.3s

  4. stored-key — the same revocation, and the agent acts anyway
    the agent still acts after the connection was revoked
&lt;/span&gt;&lt;span class="gp"&gt;    status=succeeded — Reviewed the open issues and commented on issue #&lt;/span&gt;1 ...
&lt;span class="go"&gt;
EXIT CODE = 1
&lt;/span&gt;&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;Exit code 1. The report names the step, the assertion, and the observed value. Every other assertion still passed, so the failure stayed isolated rather than cascading. I then restored the file and verified the restoration by hash rather than by eye, so the green result stands for the exact file that ships.&lt;/p&gt;

&lt;h2&gt;
  
  
  Honest Limitations
&lt;/h2&gt;

&lt;p&gt;Read these as written. Do not soften them.&lt;/p&gt;

&lt;p&gt;&lt;strong&gt;Revocation does not cancel a token that GitHub already issued.&lt;/strong&gt; Kinde stops at once. A token already handed out stays valid at GitHub until it expires. I measured it still valid 37.6 seconds after the revoke, and nothing in the revoke path touches GitHub. To cancel the outstanding token, somebody revokes it at GitHub. That control belongs to GitHub, and Kinde does not have it. It sits on the OAuth app's own settings page.&lt;/p&gt;

&lt;p&gt;&lt;a href="https://media2.dev.to/dynamic/image/width=800%2Cheight=%2Cfit=scale-down%2Cgravity=auto%2Cformat=auto/https%3A%2F%2Fdev-to-uploads.s3.us-east-2.amazonaws.com%2Fuploads%2Farticles%2Fl2lgwdt7z5u8ith2c78g.png" class="article-body-image-wrapper"&gt;&lt;img src="https://media2.dev.to/dynamic/image/width=800%2Cheight=%2Cfit=scale-down%2Cgravity=auto%2Cformat=auto/https%3A%2F%2Fdev-to-uploads.s3.us-east-2.amazonaws.com%2Fuploads%2Farticles%2Fl2lgwdt7z5u8ith2c78g.png" alt="The General settings page of a GitHub OAuth app, cropped to one row. On the left it reads 1 user. On the right sits a red button labelled Revoke all user tokens." width="800" height="67"&gt;&lt;/a&gt;&lt;/p&gt;

&lt;p&gt;&lt;strong&gt;The token life is real, and it depends on one setting.&lt;/strong&gt; The brokered token is an opaque 40 character &lt;code&gt;gho_&lt;/code&gt; token. It is not a JWT, so there is nothing to decode. Kinde reported an expiry of &lt;code&gt;2026-08-15T02:09:08.871+01:00&lt;/code&gt;, which was 28761 seconds ahead, or 7.99 hours. That matches GitHub's documented eight hour user access token (&lt;a href="https://docs.github.com/en/apps/creating-github-apps/authenticating-with-a-github-app/refreshing-user-access-tokens" rel="noopener noreferrer"&gt;GitHub docs&lt;/a&gt;). GitHub issues it that way because the OAuth app has &lt;strong&gt;Token expiration&lt;/strong&gt; switched on, under Optional features. GitHub's own wording for that setting states the eight hours, and states something else worth reading twice.&lt;/p&gt;

&lt;p&gt;&lt;a href="https://media2.dev.to/dynamic/image/width=800%2Cheight=%2Cfit=scale-down%2Cgravity=auto%2Cformat=auto/https%3A%2F%2Fdev-to-uploads.s3.us-east-2.amazonaws.com%2Fuploads%2Farticles%2Fua5e97yvcwcrk4iiwvyh.png" class="article-body-image-wrapper"&gt;&lt;img src="https://media2.dev.to/dynamic/image/width=800%2Cheight=%2Cfit=scale-down%2Cgravity=auto%2Cformat=auto/https%3A%2F%2Fdev-to-uploads.s3.us-east-2.amazonaws.com%2Fuploads%2Farticles%2Fua5e97yvcwcrk4iiwvyh.png" alt="The Optional features page of a GitHub OAuth app. A panel headed Token expiration reads: Access tokens will expire after 8 hours. A refresh token will be provided which can be used to request an updated access token. Existing tokens are not affected. A button beside it reads Opt-out, so the feature is currently active." width="799" height="430"&gt;&lt;/a&gt;&lt;/p&gt;

&lt;p&gt;"Existing tokens are not affected." GitHub is describing what happens when you toggle the setting, but the same principle runs through this whole article: a token already issued keeps its own life. Turn the setting off and the token never expires at all, and this whole argument gets weaker.&lt;/p&gt;

&lt;p&gt;&lt;strong&gt;Kinde returns the same token again, not a new one.&lt;/strong&gt; Two requests inside the same eight hour window returned the identical token, confirmed by fingerprint:&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight console"&gt;&lt;code&gt;&lt;span class="go"&gt;fingerprint    7ea1fb9d9fa2
second fetch   fingerprint 7ea1fb9d9fa2 — SAME token replayed
&lt;/span&gt;&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;So the correct claim is &lt;strong&gt;fetch per action, store never&lt;/strong&gt;. The claim is not that each action gets a fresh token. The security property comes from the app holding nothing between actions.&lt;/p&gt;

&lt;p&gt;&lt;strong&gt;Kinde holds the refresh token.&lt;/strong&gt; The app never receives one. The evidence is positive rather than merely absent. An eight hour token can only be renewed with a refresh token, so one exists, and it never reaches my app. My app cannot renew access on its own.&lt;/p&gt;

&lt;p&gt;&lt;strong&gt;The read queries in my demo use an id, not access control.&lt;/strong&gt; Every write needs a server secret that the browser does not have, so the browser cannot forge a record or change the mode. The read queries trust the id they receive. That is enough for a single operator demo. It is not access control, and I would wire the identity provider into the data layer before shipping this to many users.&lt;/p&gt;

&lt;h2&gt;
  
  
  What This Design Costs
&lt;/h2&gt;

&lt;p&gt;Be honest about the trade you make.&lt;/p&gt;

&lt;p&gt;&lt;strong&gt;You add a network call to every action.&lt;/strong&gt; The broker asks Kinde before it calls GitHub. In my measurements a successful token request took 439ms and 468ms. For an agent that makes three tool calls, that is about 1.4 extra seconds per run. You cannot buy that back with a cache without giving up the property that makes revocation work.&lt;/p&gt;

&lt;p&gt;&lt;strong&gt;You add a dependency to the hot path.&lt;/strong&gt; If Kinde is down, your agent stops. I treat that as correct behaviour rather than a fault, because the alternative is to keep a credential that nobody can take back. Your deadline and your refusal message then matter, since users will see them.&lt;/p&gt;

&lt;p&gt;&lt;strong&gt;You need somewhere to put the session handle.&lt;/strong&gt; The app stores no token, but it does store the handle and the connection state. That is a small table, and it holds nothing that works on its own.&lt;/p&gt;

&lt;p&gt;&lt;strong&gt;You give up one convenience.&lt;/strong&gt; With a stored key, any part of your codebase can call GitHub. With a broker, everything routes through one function. That is the point, and it does mean a small amount of plumbing when you add an action.&lt;/p&gt;

&lt;p&gt;The trade buys you three things. The user can cut the agent off centrally. Your database holds no live third party access. And your audit trail records every decision, including the ones where the broker said no.&lt;/p&gt;

&lt;h2&gt;
  
  
  What You've Built, and Where to Take It
&lt;/h2&gt;

&lt;p&gt;You've built a complete path: a user signs in, connects GitHub through Kinde, and an OpenAI agent acts in their account while the app stores no GitHub token. One revocation cuts the agent off within a single action, and the operator stays signed in.&lt;/p&gt;

&lt;p&gt;The shape transfers to any third party API, not only GitHub. Eight things are worth carrying over.&lt;/p&gt;

&lt;p&gt;&lt;strong&gt;Put one function between your code and the third party.&lt;/strong&gt; Everything goes through it. Add a test that walks your source tree and asserts exactly one module names the API host. Structure beats discipline, because the test fails the build and a convention does not.&lt;/p&gt;

&lt;p&gt;&lt;strong&gt;Give handlers a bound caller, not the credential.&lt;/strong&gt; If a function never holds a token, it cannot leak one. This removes a whole class of review question.&lt;/p&gt;

&lt;p&gt;&lt;strong&gt;Decide the mode on the server, from deployment configuration.&lt;/strong&gt; Make the unsafe value an exact match and let everything else fall to the safe one. Then a typo fails safe.&lt;/p&gt;

&lt;p&gt;&lt;strong&gt;Make a refusal a normal result.&lt;/strong&gt; The broker returns a typed outcome instead of throwing. Callers handle refusal as data, and the audit trail records it like anything else.&lt;/p&gt;

&lt;p&gt;&lt;strong&gt;Distinguish refusal kinds.&lt;/strong&gt; A missing credential means stop. A bad argument means the caller can fix it. Blurring the two either produces retry loops or blocks legitimate self correction.&lt;/p&gt;

&lt;p&gt;&lt;strong&gt;Fetch per action and keep nothing.&lt;/strong&gt; This is the part that makes central revocation bite. If you cache, you reopen the window.&lt;/p&gt;

&lt;p&gt;&lt;strong&gt;Test the outage, do not reason about it.&lt;/strong&gt; Point the issuer URL somewhere unroutable and see what your code does. I found a real bug that way.&lt;/p&gt;

&lt;p&gt;&lt;strong&gt;Write down the limitation that hurts.&lt;/strong&gt; The demo is stronger when it says plainly that revocation does not reach an already issued token. A reader who discovers that on their own stops trusting everything else you wrote.&lt;/p&gt;

&lt;h2&gt;
  
  
  Wrapping Up
&lt;/h2&gt;

&lt;p&gt;The demo makes one argument, and it holds up under measurement. An agent can act inside a user's real GitHub account without your app ever storing a GitHub token, and the user can cut it off centrally.&lt;/p&gt;

&lt;p&gt;The part worth remembering is the reason it works. Revoking at Kinde does not reach into GitHub and cancel a token that GitHub already issued, and I measured that token still working 37.6 seconds later. The cutoff is instant because the broker keeps nothing between actions, so no credential is left inside the app for the agent to keep using. Cache the token for even a few seconds and you give that property away.&lt;/p&gt;

&lt;p&gt;Take the honest limitation with you as well. To cancel an outstanding token too, the user revokes the authorization on GitHub, and that control belongs to GitHub rather than to &lt;a href="https://kinde.com/?utm_source=devto&amp;amp;utm_medium=content&amp;amp;utm_campaign=shola&amp;amp;campaignid=chatgptapp&amp;amp;network=&amp;amp;adgroup=&amp;amp;keyword=&amp;amp;matchtype=&amp;amp;creative=23&amp;amp;device=&amp;amp;adposition=" rel="noopener noreferrer"&gt;Kinde&lt;/a&gt;.&lt;/p&gt;

&lt;h2&gt;
  
  
  Resources
&lt;/h2&gt;

&lt;h3&gt;
  
  
  Source code
&lt;/h3&gt;

&lt;p&gt;The complete demo is on GitHub under the MIT licence, so you can run it, read it, and reshape it. The README carries the same measured limitations as this article.&lt;/p&gt;

&lt;h3&gt;
  
  
  Core documentation
&lt;/h3&gt;

&lt;ul&gt;
&lt;li&gt;
&lt;a href="https://docs.kinde.com/integrate/connected-apps/add-connected-apps/" rel="noopener noreferrer"&gt;Kinde: add connected apps&lt;/a&gt;: the &lt;code&gt;auth_url&lt;/code&gt;, &lt;code&gt;token&lt;/code&gt;, and &lt;code&gt;revoke&lt;/code&gt; flow this build uses&lt;/li&gt;
&lt;li&gt;
&lt;a href="https://docs.github.com/en/apps/creating-github-apps/authenticating-with-a-github-app/refreshing-user-access-tokens" rel="noopener noreferrer"&gt;GitHub: refreshing user access tokens&lt;/a&gt;: the eight hour token life and the refresh token&lt;/li&gt;
&lt;li&gt;
&lt;a href="https://docs.github.com/en/authentication/keeping-your-account-and-data-secure/token-expiration-and-revocation" rel="noopener noreferrer"&gt;GitHub: token expiration and revocation&lt;/a&gt;: what expires, and what a user can revoke themselves&lt;/li&gt;
&lt;li&gt;
&lt;a href="https://docs.github.com/en/apps/oauth-apps/building-oauth-apps/authorizing-oauth-apps" rel="noopener noreferrer"&gt;GitHub: authorizing OAuth apps&lt;/a&gt;: the consent screen the user sees&lt;/li&gt;
&lt;/ul&gt;

&lt;h3&gt;
  
  
  Context for the problem
&lt;/h3&gt;

&lt;ul&gt;
&lt;li&gt;
&lt;a href="https://openai.com/index/introducing-company-knowledge/" rel="noopener noreferrer"&gt;OpenAI: company knowledge in ChatGPT&lt;/a&gt;: connectors for Slack, SharePoint, Google Drive, and GitHub&lt;/li&gt;
&lt;li&gt;
&lt;a href="https://openai.com/index/introducing-workspace-agents-in-chatgpt/" rel="noopener noreferrer"&gt;OpenAI: workspace agents in ChatGPT&lt;/a&gt;: agents that run workflows across connected apps&lt;/li&gt;
&lt;/ul&gt;

&lt;h3&gt;
  
  
  Build tooling
&lt;/h3&gt;

&lt;ul&gt;
&lt;li&gt;
&lt;a href="https://nextjs.org/docs/app" rel="noopener noreferrer"&gt;Next.js App Router&lt;/a&gt;: the server actions and route handlers the console uses&lt;/li&gt;
&lt;li&gt;
&lt;a href="https://docs.convex.dev/" rel="noopener noreferrer"&gt;Convex&lt;/a&gt;: the data store behind the audit trail and the live timeline&lt;/li&gt;
&lt;li&gt;
&lt;a href="https://platform.openai.com/docs/api-reference/responses" rel="noopener noreferrer"&gt;OpenAI Responses API&lt;/a&gt;: the tool-calling endpoint the agent loop uses&lt;/li&gt;
&lt;li&gt;
&lt;a href="https://vitest.dev/" rel="noopener noreferrer"&gt;Vitest&lt;/a&gt;: the runner behind the structural tests&lt;/li&gt;
&lt;/ul&gt;

</description>
      <category>kinde</category>
      <category>ai</category>
      <category>openai</category>
      <category>agents</category>
    </item>
    <item>
      <title>How to Require Step-Up Auth Before a Claude Agent's Destructive Actions With Kinde</title>
      <dc:creator>Shola Jegede</dc:creator>
      <pubDate>Thu, 13 Aug 2026 23:33:34 +0000</pubDate>
      <link>https://dev.to/sholajegede/how-to-require-step-up-auth-before-a-claude-agents-destructive-actions-with-kinde-1ck2</link>
      <guid>https://dev.to/sholajegede/how-to-require-step-up-auth-before-a-claude-agents-destructive-actions-with-kinde-1ck2</guid>
      <description>&lt;p&gt;An agent deleted a document at 22:45. The person who authorised it had signed in at 16:57.&lt;/p&gt;

&lt;p&gt;Nobody was at the keyboard. No token had expired. No rule was broken. The access token was valid for another nineteen hours, and it said exactly what it said six hours earlier: this person is allowed to delete documents. The agent read that as permission and acted.&lt;/p&gt;

&lt;p&gt;The token was right about identity. It was silent about presence.&lt;/p&gt;

&lt;p&gt;In this tutorial, you'll build a records console where a Claude agent runs read-only tools freely, and every destructive action, delete, refund, and deploy, stops at a server-side check. That check asks one question: how long ago did a human actually authenticate? If the answer is too long ago, the server holds the action until the person signs in again. The build uses the Anthropic API for the agent, Kinde for identity, and Convex for state and the audit trail.&lt;/p&gt;

&lt;p&gt;By the end, you'll have built:&lt;/p&gt;

&lt;ul&gt;
&lt;li&gt;A tool registry that separates safe tools from destructive ones, and gives every destructive tool its own freshness window in seconds&lt;/li&gt;
&lt;li&gt;One enforcement seam that every tool call passes through, with JWKS verification and RS256 pinned&lt;/li&gt;
&lt;li&gt;A freshness check built on the OpenID Connect &lt;code&gt;auth_time&lt;/code&gt; claim, read from the ID token and verified server-side at the moment of the call&lt;/li&gt;
&lt;li&gt;A Claude tool-calling loop that carries the signed-in person's delegated identity and holds no credential of its own&lt;/li&gt;
&lt;li&gt;A complete step-up round trip: the seam holds the action, the person re-authenticates, the agent re-presents the same call, and the seam releases it&lt;/li&gt;
&lt;li&gt;An audit trail that keeps recording when its own storage is unreachable&lt;/li&gt;
&lt;li&gt;A blanket mode that reproduces the failure on the same code, so you can watch the same task slip through&lt;/li&gt;
&lt;/ul&gt;

&lt;p&gt;Every number, reason code, and token claim in this tutorial is measured from that running system.&lt;/p&gt;

&lt;h2&gt;
  
  
  Table of contents
&lt;/h2&gt;

&lt;ol&gt;
&lt;li&gt;Why blanket consent fails&lt;/li&gt;
&lt;li&gt;The demo and its three services&lt;/li&gt;
&lt;li&gt;The freshness primitive: auth_time&lt;/li&gt;
&lt;li&gt;Why iat and exp cannot stand in&lt;/li&gt;
&lt;li&gt;Building the seam&lt;/li&gt;
&lt;li&gt;The tool registry&lt;/li&gt;
&lt;li&gt;The Claude agent loop&lt;/li&gt;
&lt;li&gt;Good manners are not enforcement&lt;/li&gt;
&lt;li&gt;The round trip&lt;/li&gt;
&lt;li&gt;The two negative cases&lt;/li&gt;
&lt;li&gt;Blanket against step-up&lt;/li&gt;
&lt;li&gt;Production hardening&lt;/li&gt;
&lt;li&gt;Honest limitations&lt;/li&gt;
&lt;li&gt;Proving the check can fail&lt;/li&gt;
&lt;li&gt;How to map this to your app&lt;/li&gt;
&lt;li&gt;Resources&lt;/li&gt;
&lt;/ol&gt;

&lt;h2&gt;
  
  
  Why blanket consent fails
&lt;/h2&gt;

&lt;p&gt;The common answer to agent risk is to ask the person to approve each action. Anthropic measured what that produces. In &lt;a href="https://www.anthropic.com/engineering/how-we-contain-claude" rel="noopener noreferrer"&gt;How we contain Claude across products&lt;/a&gt;, their engineering team reports:&lt;/p&gt;

&lt;blockquote&gt;
&lt;p&gt;Our telemetry showed users approved roughly 93% of permission prompts.&lt;/p&gt;
&lt;/blockquote&gt;

&lt;p&gt;And the mechanism behind it:&lt;/p&gt;

&lt;blockquote&gt;
&lt;p&gt;The more approvals a user sees, the less attention they pay to each, becoming over time much less diligent in their supervision.&lt;/p&gt;
&lt;/blockquote&gt;

&lt;p&gt;A prompt stream trains people to clear prompts. The approval stops being a decision and becomes a reflex, and the one prompt that mattered gets cleared with the rest.&lt;/p&gt;

&lt;p&gt;Anthropic's own answer was &lt;strong&gt;containment&lt;/strong&gt;, not step-up authentication. They moved the defence into the environment: ephemeral containers, sandboxes, and virtual machines that limit what an agent can reach. They also built an auto mode that approves safer actions automatically, so the prompt stream gets shorter.&lt;/p&gt;

&lt;p&gt;Step-up authentication answers a different question. Containment limits the blast radius. Step-up asks whether the person is still here, right now, for this one irreversible action. The two are complementary:&lt;/p&gt;

&lt;ul&gt;
&lt;li&gt;Containment handles the many. Most actions become safe by construction and need no prompt.&lt;/li&gt;
&lt;li&gt;Step-up handles the few. It applies high friction to a small set of irreversible actions.&lt;/li&gt;
&lt;/ul&gt;

&lt;p&gt;One design rule follows. A read-only tool must never produce a prompt. Every unnecessary prompt spends the attention you need for the prompt that matters.&lt;/p&gt;

&lt;h2&gt;
  
  
  The demo and its three services
&lt;/h2&gt;

&lt;p&gt;The build is an operations console. An agent works on records: invoices, releases, and documents. It has six tools.&lt;/p&gt;

&lt;div class="table-wrapper-paragraph"&gt;&lt;table&gt;
&lt;thead&gt;
&lt;tr&gt;
&lt;th&gt;Tool&lt;/th&gt;
&lt;th&gt;Destructive&lt;/th&gt;
&lt;th&gt;Freshness window&lt;/th&gt;
&lt;/tr&gt;
&lt;/thead&gt;
&lt;tbody&gt;
&lt;tr&gt;
&lt;td&gt;&lt;code&gt;list_records&lt;/code&gt;&lt;/td&gt;
&lt;td&gt;no&lt;/td&gt;
&lt;td&gt;none&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;&lt;code&gt;get_record&lt;/code&gt;&lt;/td&gt;
&lt;td&gt;no&lt;/td&gt;
&lt;td&gt;none&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;&lt;code&gt;summarize_records&lt;/code&gt;&lt;/td&gt;
&lt;td&gt;no&lt;/td&gt;
&lt;td&gt;none&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;&lt;code&gt;delete_record&lt;/code&gt;&lt;/td&gt;
&lt;td&gt;yes&lt;/td&gt;
&lt;td&gt;300s&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;&lt;code&gt;refund_payment&lt;/code&gt;&lt;/td&gt;
&lt;td&gt;yes&lt;/td&gt;
&lt;td&gt;120s&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;&lt;code&gt;deploy_release&lt;/code&gt;&lt;/td&gt;
&lt;td&gt;yes&lt;/td&gt;
&lt;td&gt;120s&lt;/td&gt;
&lt;/tr&gt;
&lt;/tbody&gt;
&lt;/table&gt;&lt;/div&gt;

&lt;p&gt;Three services do the work. The &lt;strong&gt;Anthropic API&lt;/strong&gt; runs the agent, with the model id read from an environment variable. &lt;a href="https://kinde.com/?utm_source=devto&amp;amp;utm_medium=content&amp;amp;utm_campaign=shola&amp;amp;campaignid=chatgptapp&amp;amp;network=&amp;amp;adgroup=&amp;amp;keyword=&amp;amp;matchtype=&amp;amp;creative=22&amp;amp;device=&amp;amp;adposition=" rel="noopener noreferrer"&gt;&lt;strong&gt;Kinde&lt;/strong&gt; is the identity provider&lt;/a&gt;, running OpenID Connect with authorization code and PKCE. &lt;strong&gt;Convex&lt;/strong&gt; stores the registry, the records, the run timeline, and the audit trail.&lt;/p&gt;

&lt;p&gt;The windows differ by damage. A document delete gets 300 seconds because a backup can restore it. A refund and a production deploy get 120 seconds, because one moves money and the other reaches customers the moment it lands.&lt;/p&gt;

&lt;h2&gt;
  
  
  The freshness primitive: auth_time
&lt;/h2&gt;

&lt;p&gt;OpenID Connect defines &lt;code&gt;auth_time&lt;/code&gt;: the moment the person last authenticated interactively, in seconds since the epoch. It is the only standard claim that tracks a human rather than a token.&lt;/p&gt;

&lt;p&gt;Two real tokens from the same sign-in, decoded:&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="err"&gt;//&lt;/span&gt;&lt;span class="w"&gt; &lt;/span&gt;&lt;span class="err"&gt;ID&lt;/span&gt;&lt;span class="w"&gt; &lt;/span&gt;&lt;span class="err"&gt;token&lt;/span&gt;&lt;span class="w"&gt;                              &lt;/span&gt;&lt;span class="err"&gt;//&lt;/span&gt;&lt;span class="w"&gt; &lt;/span&gt;&lt;span class="err"&gt;Access&lt;/span&gt;&lt;span class="w"&gt; &lt;/span&gt;&lt;span class="err"&gt;token&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="nl"&gt;"sub"&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt;&lt;span class="w"&gt; &lt;/span&gt;&lt;span class="s2"&gt;"kp_79a5daf465584…"&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt;&lt;span class="w"&gt;              &lt;/span&gt;&lt;span class="nl"&gt;"sub"&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt;&lt;span class="w"&gt; &lt;/span&gt;&lt;span class="s2"&gt;"kp_79a5daf465584…"&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt;&lt;span class="w"&gt;
  &lt;/span&gt;&lt;span class="nl"&gt;"auth_time"&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt;&lt;span class="w"&gt; &lt;/span&gt;&lt;span class="mi"&gt;1786553833&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt;&lt;span class="w"&gt;   &lt;/span&gt;&lt;span class="err"&gt;//&lt;/span&gt;&lt;span class="w"&gt; &lt;/span&gt;&lt;span class="err"&gt;&amp;lt;--&lt;/span&gt;&lt;span class="w"&gt; &lt;/span&gt;&lt;span class="err"&gt;here&lt;/span&gt;&lt;span class="w"&gt;   &lt;/span&gt;&lt;span class="err"&gt;//&lt;/span&gt;&lt;span class="w"&gt; &lt;/span&gt;&lt;span class="err"&gt;no&lt;/span&gt;&lt;span class="w"&gt; &lt;/span&gt;&lt;span class="err"&gt;auth_time&lt;/span&gt;&lt;span class="w"&gt;
  &lt;/span&gt;&lt;span class="nl"&gt;"iat"&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt;&lt;span class="w"&gt; &lt;/span&gt;&lt;span class="mi"&gt;1786553834&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt;&lt;span class="w"&gt;                       &lt;/span&gt;&lt;span class="nl"&gt;"iat"&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt;&lt;span class="w"&gt; &lt;/span&gt;&lt;span class="mi"&gt;1786553834&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt;&lt;span class="w"&gt;
  &lt;/span&gt;&lt;span class="nl"&gt;"exp"&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt;&lt;span class="w"&gt; &lt;/span&gt;&lt;span class="mi"&gt;1786557434&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt;&lt;span class="w"&gt;         &lt;/span&gt;&lt;span class="err"&gt;//&lt;/span&gt;&lt;span class="w"&gt; &lt;/span&gt;&lt;span class="err"&gt;+&lt;/span&gt;&lt;span class="mi"&gt;1&lt;/span&gt;&lt;span class="err"&gt;h&lt;/span&gt;&lt;span class="w"&gt;        &lt;/span&gt;&lt;span class="nl"&gt;"exp"&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt;&lt;span class="w"&gt; &lt;/span&gt;&lt;span class="mi"&gt;1786640234&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt;&lt;span class="w"&gt;   &lt;/span&gt;&lt;span class="err"&gt;//&lt;/span&gt;&lt;span class="w"&gt; &lt;/span&gt;&lt;span class="err"&gt;+&lt;/span&gt;&lt;span class="mi"&gt;24&lt;/span&gt;&lt;span class="err"&gt;h&lt;/span&gt;&lt;span class="w"&gt;
  &lt;/span&gt;&lt;span class="nl"&gt;"aud"&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="s2"&gt;"d4ef3c61eb19…"&lt;/span&gt;&lt;span class="p"&gt;],&lt;/span&gt;&lt;span class="w"&gt;                &lt;/span&gt;&lt;span class="nl"&gt;"aud"&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;"iss"&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt;&lt;span class="w"&gt; &lt;/span&gt;&lt;span class="s2"&gt;"https://devrelstudio.kinde.com"&lt;/span&gt;&lt;span class="w"&gt;  &lt;/span&gt;&lt;span class="nl"&gt;"iss"&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt;&lt;span class="w"&gt; &lt;/span&gt;&lt;span class="s2"&gt;"https://devrelstudio.kinde.com"&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 differences decide the whole design.&lt;/p&gt;

&lt;div class="table-wrapper-paragraph"&gt;&lt;table&gt;
&lt;thead&gt;
&lt;tr&gt;
&lt;th&gt;&lt;/th&gt;
&lt;th&gt;ID token&lt;/th&gt;
&lt;th&gt;Access token&lt;/th&gt;
&lt;/tr&gt;
&lt;/thead&gt;
&lt;tbody&gt;
&lt;tr&gt;
&lt;td&gt;&lt;code&gt;auth_time&lt;/code&gt;&lt;/td&gt;
&lt;td&gt;present&lt;/td&gt;
&lt;td&gt;&lt;strong&gt;absent&lt;/strong&gt;&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;&lt;code&gt;aud&lt;/code&gt;&lt;/td&gt;
&lt;td&gt;client id&lt;/td&gt;
&lt;td&gt;&lt;code&gt;[]&lt;/code&gt;&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;Lifetime&lt;/td&gt;
&lt;td&gt;1 hour&lt;/td&gt;
&lt;td&gt;&lt;strong&gt;24 hours&lt;/strong&gt;&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;
&lt;code&gt;amr&lt;/code&gt; / &lt;code&gt;acr&lt;/code&gt;
&lt;/td&gt;
&lt;td&gt;absent&lt;/td&gt;
&lt;td&gt;absent&lt;/td&gt;
&lt;/tr&gt;
&lt;/tbody&gt;
&lt;/table&gt;&lt;/div&gt;

&lt;p&gt;The access token is the credential a client presents at an API boundary. It carries no evidence of human presence. The ID token carries that evidence and expires twenty-three hours sooner.&lt;/p&gt;

&lt;p&gt;&lt;strong&gt;The access token lives 24 hours. The evidence of human presence lives 1 hour.&lt;/strong&gt; Blanket consent ignores that gap. The freshness check closes it.&lt;/p&gt;

&lt;p&gt;&lt;a href="https://media2.dev.to/dynamic/image/width=800%2Cheight=%2Cfit=scale-down%2Cgravity=auto%2Cformat=auto/https%3A%2F%2Fdev-to-uploads.s3.us-east-2.amazonaws.com%2Fuploads%2Farticles%2F8g7dtxhgwmvn03ssj7c5.png" class="article-body-image-wrapper"&gt;&lt;img src="https://media2.dev.to/dynamic/image/width=800%2Cheight=%2Cfit=scale-down%2Cgravity=auto%2Cformat=auto/https%3A%2F%2Fdev-to-uploads.s3.us-east-2.amazonaws.com%2Fuploads%2Farticles%2F8g7dtxhgwmvn03ssj7c5.png" alt="A 24-hour timeline comparing two lifetimes. The access token bar runs the full 24 hours from 00:00. Below it, the evidence of human presence is an ID token bar that runs for 1 hour from 00:00. A third bar, marked critical, covers the remaining 23 hours from 01:00 onward and is labelled " width="800" height="456"&gt;&lt;/a&gt;&lt;/p&gt;

&lt;p&gt;For twenty-three of those twenty-four hours, an agent holds a valid credential and the system has no fresh evidence that anyone is there.&lt;/p&gt;

&lt;h3&gt;
  
  
  The discovery document does not mention it
&lt;/h3&gt;

&lt;p&gt;&lt;a href="https://kinde.com/?utm_source=devto&amp;amp;utm_medium=content&amp;amp;utm_campaign=shola&amp;amp;campaignid=chatgptapp&amp;amp;network=&amp;amp;adgroup=&amp;amp;keyword=&amp;amp;matchtype=&amp;amp;creative=22&amp;amp;device=&amp;amp;adposition=" rel="noopener noreferrer"&gt;Kinde&lt;/a&gt; publishes a discovery document. Its claims list is short:&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;"claims_supported"&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="s2"&gt;"aud"&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt;&lt;span class="w"&gt; &lt;/span&gt;&lt;span class="s2"&gt;"exp"&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt;&lt;span class="w"&gt; &lt;/span&gt;&lt;span class="s2"&gt;"iat"&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt;&lt;span class="w"&gt; &lt;/span&gt;&lt;span class="s2"&gt;"iss"&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt;&lt;span class="w"&gt; &lt;/span&gt;&lt;span class="s2"&gt;"sub"&lt;/span&gt;&lt;span class="p"&gt;],&lt;/span&gt;&lt;span class="w"&gt;
  &lt;/span&gt;&lt;span class="nl"&gt;"code_challenge_methods_supported"&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="s2"&gt;"S256"&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;&lt;code&gt;auth_time&lt;/code&gt; does not appear. The ID token carries it anyway.&lt;/p&gt;

&lt;p&gt;This is spec-consistent. OIDC Core defines &lt;code&gt;auth_time&lt;/code&gt; as an ID Token claim, and &lt;code&gt;claims_supported&lt;/code&gt; is informational rather than exhaustive. The practical lesson: read a real token before you design against a provider.&lt;/p&gt;

&lt;p&gt;The seam therefore cannot prove freshness from the access token. It verifies two tokens and binds them together.&lt;/p&gt;

&lt;h2&gt;
  
  
  Why iat and exp cannot stand in
&lt;/h2&gt;

&lt;p&gt;&lt;code&gt;iat&lt;/code&gt; and &lt;code&gt;exp&lt;/code&gt; look like freshness. They are not. Both move whenever a token is minted, and a token can be minted with no human present.&lt;/p&gt;

&lt;p&gt;The build tested this against the live provider. First, a refresh while the ID token was still valid:&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;"authTimeBefore"&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt;&lt;span class="w"&gt; &lt;/span&gt;&lt;span class="mi"&gt;1786552841&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt;&lt;span class="w"&gt;
  &lt;/span&gt;&lt;span class="nl"&gt;"authTimeAfter"&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt;&lt;span class="w"&gt; &lt;/span&gt;&lt;span class="mi"&gt;1786552841&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt;&lt;span class="w"&gt;
  &lt;/span&gt;&lt;span class="nl"&gt;"authTimeMoved"&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt;&lt;span class="w"&gt; &lt;/span&gt;&lt;span class="kc"&gt;false&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt;&lt;span class="w"&gt;
  &lt;/span&gt;&lt;span class="nl"&gt;"idTokenChanged"&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt;&lt;span class="w"&gt; &lt;/span&gt;&lt;span class="kc"&gt;false&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt;&lt;span class="w"&gt;
  &lt;/span&gt;&lt;span class="nl"&gt;"accessTokenChanged"&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt;&lt;span class="w"&gt; &lt;/span&gt;&lt;span class="kc"&gt;false&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt;&lt;span class="w"&gt;
  &lt;/span&gt;&lt;span class="nl"&gt;"verdict"&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt;&lt;span class="w"&gt; &lt;/span&gt;&lt;span class="s2"&gt;"INCONCLUSIVE — the refresh returned the same ID token"&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;&lt;a href="https://kinde.com/?utm_source=devto&amp;amp;utm_medium=content&amp;amp;utm_campaign=shola&amp;amp;campaignid=chatgptapp&amp;amp;network=&amp;amp;adgroup=&amp;amp;keyword=&amp;amp;matchtype=&amp;amp;creative=22&amp;amp;device=&amp;amp;adposition=" rel="noopener noreferrer"&gt;Kinde&lt;/a&gt; returned the byte-identical token. Nothing was minted, so nothing was proved. Reporting that as a pass would be a false result, so the check reports it as inconclusive.&lt;/p&gt;

&lt;p&gt;The decisive test needs an expired ID token:&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;"authTimeBefore"&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt;&lt;span class="w"&gt; &lt;/span&gt;&lt;span class="mi"&gt;1786553833&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt;&lt;span class="w"&gt;
  &lt;/span&gt;&lt;span class="nl"&gt;"authTimeAfter"&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt;&lt;span class="w"&gt; &lt;/span&gt;&lt;span class="mi"&gt;1786553833&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt;&lt;span class="w"&gt;
  &lt;/span&gt;&lt;span class="nl"&gt;"authTimeMoved"&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt;&lt;span class="w"&gt; &lt;/span&gt;&lt;span class="kc"&gt;false&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt;&lt;span class="w"&gt;
  &lt;/span&gt;&lt;span class="nl"&gt;"idTokenChanged"&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt;&lt;span class="w"&gt; &lt;/span&gt;&lt;span class="kc"&gt;true&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt;&lt;span class="w"&gt;
  &lt;/span&gt;&lt;span class="nl"&gt;"idTokenWasExpired"&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt;&lt;span class="w"&gt; &lt;/span&gt;&lt;span class="kc"&gt;true&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt;&lt;span class="w"&gt;
  &lt;/span&gt;&lt;span class="nl"&gt;"issuedAtMoved"&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt;&lt;span class="w"&gt; &lt;/span&gt;&lt;span class="kc"&gt;true&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt;&lt;span class="w"&gt;
  &lt;/span&gt;&lt;span class="nl"&gt;"tokenIdMoved"&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt;&lt;span class="w"&gt; &lt;/span&gt;&lt;span class="kc"&gt;true&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt;&lt;span class="w"&gt;
  &lt;/span&gt;&lt;span class="nl"&gt;"verdict"&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt;&lt;span class="w"&gt; &lt;/span&gt;&lt;span class="s2"&gt;"auth_time held steady across a newly minted ID token"&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 provider minted a genuinely new ID token:&lt;/p&gt;

&lt;div class="table-wrapper-paragraph"&gt;&lt;table&gt;
&lt;thead&gt;
&lt;tr&gt;
&lt;th&gt;&lt;/th&gt;
&lt;th&gt;Before&lt;/th&gt;
&lt;th&gt;After&lt;/th&gt;
&lt;/tr&gt;
&lt;/thead&gt;
&lt;tbody&gt;
&lt;tr&gt;
&lt;td&gt;&lt;code&gt;jti&lt;/code&gt;&lt;/td&gt;
&lt;td&gt;&lt;code&gt;1dd46212-757e-…&lt;/code&gt;&lt;/td&gt;
&lt;td&gt;&lt;code&gt;09ba30b7-e842-…&lt;/code&gt;&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;&lt;code&gt;iat&lt;/code&gt;&lt;/td&gt;
&lt;td&gt;16:57:14Z&lt;/td&gt;
&lt;td&gt;18:02:30Z&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;&lt;code&gt;exp&lt;/code&gt;&lt;/td&gt;
&lt;td&gt;17:57:14Z&lt;/td&gt;
&lt;td&gt;19:02:30Z&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;&lt;code&gt;auth_time&lt;/code&gt;&lt;/td&gt;
&lt;td&gt;&lt;code&gt;1786553833&lt;/code&gt;&lt;/td&gt;
&lt;td&gt;&lt;code&gt;1786553833&lt;/code&gt;&lt;/td&gt;
&lt;/tr&gt;
&lt;/tbody&gt;
&lt;/table&gt;&lt;/div&gt;

&lt;p&gt;The new token reports an authentication that happened &lt;strong&gt;3,936 seconds earlier&lt;/strong&gt;, about 65 minutes. A token minted seconds ago correctly describes a human who authenticated over an hour before.&lt;/p&gt;

&lt;p&gt;That number settles the design. &lt;code&gt;auth_time&lt;/code&gt; tracks human authentication, so freshness builds on it. &lt;code&gt;iat&lt;/code&gt; and &lt;code&gt;exp&lt;/code&gt; track token issuance, so a check that asks "was this token issued recently?" passes with nobody there.&lt;/p&gt;

&lt;p&gt;The decision function never reads &lt;code&gt;iat&lt;/code&gt; or &lt;code&gt;exp&lt;/code&gt;. The comment says why, so nobody adds them later:&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight typescript"&gt;&lt;code&gt;&lt;span class="cm"&gt;/**
 * `iat` and `exp` are deliberately absent from this function. Both move when
 * a token is refreshed while no human is present, so neither can stand in for
 * human presence. Only `auth_time` survives the minting of a new token.
 */&lt;/span&gt;
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;h2&gt;
  
  
  Building the seam
&lt;/h2&gt;

&lt;p&gt;The seam is one function. Every agent tool call passes through it. There is no second path to a tool.&lt;/p&gt;

&lt;p&gt;&lt;a href="https://media2.dev.to/dynamic/image/width=800%2Cheight=%2Cfit=scale-down%2Cgravity=auto%2Cformat=auto/https%3A%2F%2Fdev-to-uploads.s3.us-east-2.amazonaws.com%2Fuploads%2Farticles%2F7z0rsdgfto5dipx987ma.png" class="article-body-image-wrapper"&gt;&lt;img src="https://media2.dev.to/dynamic/image/width=800%2Cheight=%2Cfit=scale-down%2Cgravity=auto%2Cformat=auto/https%3A%2F%2Fdev-to-uploads.s3.us-east-2.amazonaws.com%2Fuploads%2Farticles%2F7z0rsdgfto5dipx987ma.png" alt="The decision tree every tool call passes through. A call arrives and the access token is verified; a failure denies with token_invalid. The tool is looked up in the registry; an unreadable registry denies with registry_unavailable, and an unknown name denies with unknown_tool. A tool that is not destructive is allowed as safe_tool. A destructive tool must declare a freshness window; a missing window denies with registry_defect. The approval mode then splits the path. Blanket mode allows the call as blanket_mode_freshness_skipped. Step-up mode verifies the ID token, challenging with id_token_invalid on a failure and id_token_missing when there is none. It binds that token to the access token by subject, denying with subject_mismatch when the subjects differ. It then compares auth_time against the tool's window, challenging with auth_time_stale outside it and allowing with fresh_authentication inside it. All eleven outcomes, three allows, three challenges, and five denies, write one audit row carrying a correlationId." width="800" height="600"&gt;&lt;/a&gt;&lt;/p&gt;

&lt;p&gt;Every path ends in an audit row. There is no early return that skips the trail.&lt;/p&gt;

&lt;h3&gt;
  
  
  Step 1: verify the access token
&lt;/h3&gt;

&lt;p&gt;The build pins RS256 and reads keys from the provider's JWKS endpoint:&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight typescript"&gt;&lt;code&gt;&lt;span class="kd"&gt;const&lt;/span&gt; &lt;span class="nx"&gt;result&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="k"&gt;await&lt;/span&gt; &lt;span class="nf"&gt;jwtVerify&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="nx"&gt;token&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="nf"&gt;jwksFor&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="nx"&gt;metadata&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nx"&gt;jwks_uri&lt;/span&gt;&lt;span class="p"&gt;),&lt;/span&gt; &lt;span class="p"&gt;{&lt;/span&gt;
  &lt;span class="na"&gt;algorithms&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="nx"&gt;ALLOWED_ALGORITHMS&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt;      &lt;span class="c1"&gt;// ["RS256"]&lt;/span&gt;
  &lt;span class="na"&gt;issuer&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="nx"&gt;metadata&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nx"&gt;issuer&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt;
  &lt;span class="na"&gt;clockTolerance&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="mi"&gt;5&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt;
&lt;span class="p"&gt;});&lt;/span&gt;

&lt;span class="c1"&gt;// `jwtVerify` already refuses any algorithm outside the allow-list. This&lt;/span&gt;
&lt;span class="c1"&gt;// re-reads the header so the pin is visible at the call site and survives&lt;/span&gt;
&lt;span class="c1"&gt;// a future change to how the options are built.&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;result&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nx"&gt;protectedHeader&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nx"&gt;alg&lt;/span&gt; &lt;span class="o"&gt;!==&lt;/span&gt; &lt;span class="dl"&gt;"&lt;/span&gt;&lt;span class="s2"&gt;RS256&lt;/span&gt;&lt;span class="dl"&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;throw&lt;/span&gt; &lt;span class="k"&gt;new&lt;/span&gt; &lt;span class="nc"&gt;TokenVerificationError&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;
    &lt;span class="dl"&gt;"&lt;/span&gt;&lt;span class="s2"&gt;algorithm_not_allowed&lt;/span&gt;&lt;span class="dl"&gt;"&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt;
    &lt;span class="s2"&gt;`Token algorithm &lt;/span&gt;&lt;span class="p"&gt;${&lt;/span&gt;&lt;span class="nx"&gt;result&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nx"&gt;protectedHeader&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nx"&gt;alg&lt;/span&gt;&lt;span class="p"&gt;}&lt;/span&gt;&lt;span class="s2"&gt; is not RS256.`&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;The pin appears twice on purpose. The allow-list enforces it. The second check keeps it visible, so a later edit cannot drop it silently.&lt;/p&gt;

&lt;p&gt;The verifier refuses malformed claims rather than ignoring them:&lt;/p&gt;

&lt;ul&gt;
&lt;li&gt;No &lt;code&gt;sub&lt;/code&gt;, no &lt;code&gt;exp&lt;/code&gt;, or no &lt;code&gt;iat&lt;/code&gt;: refused.&lt;/li&gt;
&lt;li&gt;An &lt;code&gt;auth_time&lt;/code&gt; that is not a positive finite number: refused as malformed, not treated as absent.&lt;/li&gt;
&lt;li&gt;An &lt;code&gt;auth_time&lt;/code&gt; more than 60 seconds in the future: refused. A future &lt;code&gt;auth_time&lt;/code&gt; would make every comparison pass.&lt;/li&gt;
&lt;/ul&gt;

&lt;h3&gt;
  
  
  Step 2: read the registry, and refuse when you cannot
&lt;/h3&gt;

&lt;p&gt;An unknown tool is denied &lt;code&gt;unknown_tool&lt;/code&gt;. A registry that cannot be read is denied &lt;code&gt;registry_unavailable&lt;/code&gt;. Without the registry the seam cannot know whether a tool is destructive or what window it carries, and guessing either way is worse than stopping.&lt;/p&gt;

&lt;h3&gt;
  
  
  Step 3: read freshness from the ID token, bound by subject
&lt;/h3&gt;



&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight typescript"&gt;&lt;code&gt;&lt;span class="k"&gt;if &lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="nx"&gt;context&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nx"&gt;idToken&lt;/span&gt; &lt;span class="o"&gt;!==&lt;/span&gt; &lt;span class="kc"&gt;undefined&lt;/span&gt; &lt;span class="o"&gt;&amp;amp;&amp;amp;&lt;/span&gt; &lt;span class="nx"&gt;context&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nx"&gt;idToken&lt;/span&gt; &lt;span class="o"&gt;!==&lt;/span&gt; &lt;span class="dl"&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;try&lt;/span&gt; &lt;span class="p"&gt;{&lt;/span&gt;
    &lt;span class="kd"&gt;const&lt;/span&gt; &lt;span class="nx"&gt;idClaims&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="k"&gt;await&lt;/span&gt; &lt;span class="nf"&gt;verifyIdToken&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="nx"&gt;context&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nx"&gt;idToken&lt;/span&gt;&lt;span class="p"&gt;);&lt;/span&gt;

    &lt;span class="c1"&gt;// Binding. Without this, an ID token from any session of any person&lt;/span&gt;
    &lt;span class="c1"&gt;// could vouch for freshness on someone else's access token.&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;idClaims&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nx"&gt;sub&lt;/span&gt; &lt;span class="o"&gt;!==&lt;/span&gt; &lt;span class="nx"&gt;accessClaims&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nx"&gt;sub&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="k"&gt;await&lt;/span&gt; &lt;span class="nf"&gt;finish&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="dl"&gt;"&lt;/span&gt;&lt;span class="s2"&gt;deny&lt;/span&gt;&lt;span class="dl"&gt;"&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="dl"&gt;"&lt;/span&gt;&lt;span class="s2"&gt;subject_mismatch&lt;/span&gt;&lt;span class="dl"&gt;"&lt;/span&gt;&lt;span class="p"&gt;);&lt;/span&gt;
    &lt;span class="p"&gt;}&lt;/span&gt;

    &lt;span class="nx"&gt;observed&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nx"&gt;authTime&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="nx"&gt;idClaims&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nx"&gt;auth_time&lt;/span&gt;&lt;span class="p"&gt;;&lt;/span&gt;
    &lt;span class="nx"&gt;observed&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nx"&gt;amr&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="nx"&gt;idClaims&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nx"&gt;amr&lt;/span&gt;&lt;span class="p"&gt;;&lt;/span&gt;
  &lt;span class="p"&gt;}&lt;/span&gt; &lt;span class="k"&gt;catch&lt;/span&gt; &lt;span class="p"&gt;{&lt;/span&gt;
    &lt;span class="c1"&gt;// The ID token did not verify. Most often it expired.&lt;/span&gt;
    &lt;span class="k"&gt;return&lt;/span&gt; &lt;span class="k"&gt;await&lt;/span&gt; &lt;span class="nf"&gt;finish&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="dl"&gt;"&lt;/span&gt;&lt;span class="s2"&gt;challenge&lt;/span&gt;&lt;span class="dl"&gt;"&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="dl"&gt;"&lt;/span&gt;&lt;span class="s2"&gt;id_token_invalid&lt;/span&gt;&lt;span class="dl"&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;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;else&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;mode&lt;/span&gt; &lt;span class="o"&gt;===&lt;/span&gt; &lt;span class="dl"&gt;"&lt;/span&gt;&lt;span class="s2"&gt;step-up&lt;/span&gt;&lt;span class="dl"&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="k"&gt;await&lt;/span&gt; &lt;span class="nf"&gt;finish&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="dl"&gt;"&lt;/span&gt;&lt;span class="s2"&gt;challenge&lt;/span&gt;&lt;span class="dl"&gt;"&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="dl"&gt;"&lt;/span&gt;&lt;span class="s2"&gt;id_token_missing&lt;/span&gt;&lt;span class="dl"&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;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 access token answers "who is this". The ID token answers "when did a human last authenticate". Without the binding, a valid ID token from any session could vouch for freshness on somebody else's access token.&lt;/p&gt;

&lt;p&gt;The build separates two refusals that both fail closed:&lt;/p&gt;

&lt;ul&gt;
&lt;li&gt;
&lt;code&gt;id_token_invalid&lt;/code&gt;: the freshness evidence did not verify. Most often the ID token expired, because it lives one hour and the access token lives a day. This is the common case for a long-running agent.&lt;/li&gt;
&lt;li&gt;
&lt;code&gt;auth_time_stale&lt;/code&gt;: the ID token verified, but &lt;code&gt;auth_time&lt;/code&gt; is older than the tool's window.&lt;/li&gt;
&lt;/ul&gt;

&lt;p&gt;An early version reported both as &lt;code&gt;auth_time_missing&lt;/code&gt;. That was wrong: the claim was not missing, the token had expired. The trail now says which happened.&lt;/p&gt;

&lt;h3&gt;
  
  
  Step 4: decide
&lt;/h3&gt;

&lt;p&gt;The decision is a pure function. Tests drive the whole table with no tokens, no network, and no database.&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight typescript"&gt;&lt;code&gt;&lt;span class="k"&gt;export&lt;/span&gt; &lt;span class="kd"&gt;function&lt;/span&gt; &lt;span class="nf"&gt;decide&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="nx"&gt;input&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="nx"&gt;DecisionInput&lt;/span&gt;&lt;span class="p"&gt;):&lt;/span&gt; &lt;span class="nx"&gt;DecisionOutput&lt;/span&gt; &lt;span class="p"&gt;{&lt;/span&gt;
  &lt;span class="k"&gt;if &lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="o"&gt;!&lt;/span&gt;&lt;span class="nx"&gt;input&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nx"&gt;destructive&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="p"&gt;{&lt;/span&gt; &lt;span class="na"&gt;decision&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="dl"&gt;"&lt;/span&gt;&lt;span class="s2"&gt;allow&lt;/span&gt;&lt;span class="dl"&gt;"&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="na"&gt;reason&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="dl"&gt;"&lt;/span&gt;&lt;span class="s2"&gt;safe_tool&lt;/span&gt;&lt;span class="dl"&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;if &lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;
    &lt;span class="nx"&gt;input&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nx"&gt;maxAuthAgeSeconds&lt;/span&gt; &lt;span class="o"&gt;===&lt;/span&gt; &lt;span class="kc"&gt;undefined&lt;/span&gt; &lt;span class="o"&gt;||&lt;/span&gt;
    &lt;span class="o"&gt;!&lt;/span&gt;&lt;span class="nb"&gt;Number&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nf"&gt;isFinite&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="nx"&gt;input&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nx"&gt;maxAuthAgeSeconds&lt;/span&gt;&lt;span class="p"&gt;)&lt;/span&gt; &lt;span class="o"&gt;||&lt;/span&gt;
    &lt;span class="nx"&gt;input&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nx"&gt;maxAuthAgeSeconds&lt;/span&gt; &lt;span class="o"&gt;&amp;lt;=&lt;/span&gt; &lt;span class="mi"&gt;0&lt;/span&gt;
  &lt;span class="p"&gt;)&lt;/span&gt; &lt;span class="p"&gt;{&lt;/span&gt;
    &lt;span class="c1"&gt;// A destructive tool that declares no window cannot be checked. Allowing&lt;/span&gt;
    &lt;span class="c1"&gt;// it would mean "no limit"; that reading is the failure being fixed.&lt;/span&gt;
    &lt;span class="k"&gt;return&lt;/span&gt; &lt;span class="p"&gt;{&lt;/span&gt; &lt;span class="na"&gt;decision&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="dl"&gt;"&lt;/span&gt;&lt;span class="s2"&gt;deny&lt;/span&gt;&lt;span class="dl"&gt;"&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="na"&gt;reason&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="dl"&gt;"&lt;/span&gt;&lt;span class="s2"&gt;registry_defect&lt;/span&gt;&lt;span class="dl"&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;span class="p"&gt;}&lt;/span&gt;

  &lt;span class="kd"&gt;const&lt;/span&gt; &lt;span class="nx"&gt;authAgeSeconds&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt;
    &lt;span class="nx"&gt;input&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nx"&gt;authTime&lt;/span&gt; &lt;span class="o"&gt;===&lt;/span&gt; &lt;span class="kc"&gt;undefined&lt;/span&gt; &lt;span class="p"&gt;?&lt;/span&gt; &lt;span class="kc"&gt;undefined&lt;/span&gt; &lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="nx"&gt;input&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nx"&gt;now&lt;/span&gt; &lt;span class="o"&gt;-&lt;/span&gt; &lt;span class="nx"&gt;input&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nx"&gt;authTime&lt;/span&gt;&lt;span class="p"&gt;;&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;input&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nx"&gt;approvalMode&lt;/span&gt; &lt;span class="o"&gt;===&lt;/span&gt; &lt;span class="dl"&gt;"&lt;/span&gt;&lt;span class="s2"&gt;blanket&lt;/span&gt;&lt;span class="dl"&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="p"&gt;{&lt;/span&gt; &lt;span class="na"&gt;decision&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="dl"&gt;"&lt;/span&gt;&lt;span class="s2"&gt;allow&lt;/span&gt;&lt;span class="dl"&gt;"&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="na"&gt;reason&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="dl"&gt;"&lt;/span&gt;&lt;span class="s2"&gt;blanket_mode_freshness_skipped&lt;/span&gt;&lt;span class="dl"&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;span class="p"&gt;}&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;input&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nx"&gt;authTime&lt;/span&gt; &lt;span class="o"&gt;===&lt;/span&gt; &lt;span class="kc"&gt;undefined&lt;/span&gt;&lt;span class="p"&gt;)&lt;/span&gt; &lt;span class="p"&gt;{&lt;/span&gt;
    &lt;span class="c1"&gt;// Freshness that cannot be proved is not freshness.&lt;/span&gt;
    &lt;span class="k"&gt;return&lt;/span&gt; &lt;span class="p"&gt;{&lt;/span&gt; &lt;span class="na"&gt;decision&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="dl"&gt;"&lt;/span&gt;&lt;span class="s2"&gt;challenge&lt;/span&gt;&lt;span class="dl"&gt;"&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="na"&gt;reason&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="dl"&gt;"&lt;/span&gt;&lt;span class="s2"&gt;auth_time_missing&lt;/span&gt;&lt;span class="dl"&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;span class="p"&gt;}&lt;/span&gt;

  &lt;span class="kd"&gt;const&lt;/span&gt; &lt;span class="nx"&gt;withinWindow&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt;
    &lt;span class="nx"&gt;authAgeSeconds&lt;/span&gt;&lt;span class="o"&gt;!&lt;/span&gt; &lt;span class="o"&gt;&amp;lt;=&lt;/span&gt; &lt;span class="nx"&gt;input&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nx"&gt;maxAuthAgeSeconds&lt;/span&gt; &lt;span class="o"&gt;+&lt;/span&gt; &lt;span class="nx"&gt;input&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nx"&gt;clockSkewSeconds&lt;/span&gt;&lt;span class="p"&gt;;&lt;/span&gt;

  &lt;span class="k"&gt;return&lt;/span&gt; &lt;span class="nx"&gt;withinWindow&lt;/span&gt;
    &lt;span class="p"&gt;?&lt;/span&gt; &lt;span class="p"&gt;{&lt;/span&gt; &lt;span class="na"&gt;decision&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="dl"&gt;"&lt;/span&gt;&lt;span class="s2"&gt;allow&lt;/span&gt;&lt;span class="dl"&gt;"&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="na"&gt;reason&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="dl"&gt;"&lt;/span&gt;&lt;span class="s2"&gt;fresh_authentication&lt;/span&gt;&lt;span class="dl"&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;span class="p"&gt;:&lt;/span&gt; &lt;span class="p"&gt;{&lt;/span&gt; &lt;span class="na"&gt;decision&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="dl"&gt;"&lt;/span&gt;&lt;span class="s2"&gt;challenge&lt;/span&gt;&lt;span class="dl"&gt;"&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="na"&gt;reason&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="dl"&gt;"&lt;/span&gt;&lt;span class="s2"&gt;auth_time_stale&lt;/span&gt;&lt;span class="dl"&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;span class="p"&gt;}&lt;/span&gt;
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;Three details are worth copying.&lt;/p&gt;

&lt;p&gt;&lt;strong&gt;A missing window is a defect, not permission.&lt;/strong&gt; Treating an absent &lt;code&gt;maxAuthAgeSeconds&lt;/code&gt; as "no limit" is the failure this design closes. The function denies instead.&lt;/p&gt;

&lt;p&gt;&lt;strong&gt;Clock skew widens the window, never narrows it.&lt;/strong&gt; A provider clock slightly ahead of yours must not turn a fresh authentication into a challenge. The build adds a 30-second grace.&lt;/p&gt;

&lt;p&gt;&lt;strong&gt;Safe tools return before any freshness logic runs.&lt;/strong&gt; They cannot produce a prompt even by accident.&lt;/p&gt;

&lt;h3&gt;
  
  
  The challenge response
&lt;/h3&gt;

&lt;p&gt;A held call answers with HTTP 403 and an &lt;a href="https://www.rfc-editor.org/rfc/rfc9470.html" rel="noopener noreferrer"&gt;RFC 9470&lt;/a&gt; challenge:&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight http"&gt;&lt;code&gt;&lt;span class="k"&gt;HTTP&lt;/span&gt;&lt;span class="o"&gt;/&lt;/span&gt;&lt;span class="m"&gt;1.1&lt;/span&gt; &lt;span class="m"&gt;403&lt;/span&gt; &lt;span class="ne"&gt;Forbidden&lt;/span&gt;
&lt;span class="na"&gt;WWW-Authenticate&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="s"&gt;Bearer error="insufficient_user_authentication",&lt;/span&gt;
&lt;span class="s"&gt;  error_description="The last human authentication is older than this action&lt;/span&gt;
&lt;span class="s"&gt;  allows. Re-authenticate to continue.", max_age=120&lt;/span&gt;
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;RFC 9470 defines &lt;code&gt;insufficient_user_authentication&lt;/code&gt; for this case: the token is valid, but the authentication behind it does not meet the resource server's requirement. The &lt;code&gt;max_age&lt;/code&gt; parameter tells the client what the server needs.&lt;/p&gt;

&lt;p&gt;The body carries the same facts plus a re-authentication link:&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;"tool"&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt;&lt;span class="w"&gt; &lt;/span&gt;&lt;span class="s2"&gt;"delete_record"&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="w"&gt; &lt;/span&gt;&lt;span class="s2"&gt;"challenge"&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt;&lt;span class="w"&gt;
  &lt;/span&gt;&lt;span class="nl"&gt;"reason"&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt;&lt;span class="w"&gt; &lt;/span&gt;&lt;span class="s2"&gt;"auth_time_stale"&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt;&lt;span class="w"&gt;
  &lt;/span&gt;&lt;span class="nl"&gt;"correlationId"&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt;&lt;span class="w"&gt; &lt;/span&gt;&lt;span class="s2"&gt;"3ad3c1c9-c0cb-4c9c-a0d7-0e3f79b48ba2"&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt;&lt;span class="w"&gt;
  &lt;/span&gt;&lt;span class="nl"&gt;"authAgeSeconds"&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt;&lt;span class="w"&gt; &lt;/span&gt;&lt;span class="mi"&gt;1800&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt;&lt;span class="w"&gt;
  &lt;/span&gt;&lt;span class="nl"&gt;"maxAuthAgeSeconds"&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt;&lt;span class="w"&gt; &lt;/span&gt;&lt;span class="mi"&gt;300&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt;&lt;span class="w"&gt;
  &lt;/span&gt;&lt;span class="nl"&gt;"error"&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt;&lt;span class="w"&gt; &lt;/span&gt;&lt;span class="s2"&gt;"insufficient_user_authentication"&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt;&lt;span class="w"&gt;
  &lt;/span&gt;&lt;span class="nl"&gt;"reauthUrl"&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt;&lt;span class="w"&gt; &lt;/span&gt;&lt;span class="s2"&gt;"/api/auth/login?max_age=0&amp;amp;prompt=login&amp;amp;stepUp=1&amp;amp;returnTo=%2F"&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;&lt;code&gt;max_age=0&lt;/code&gt; and &lt;code&gt;prompt=login&lt;/code&gt; ask the provider for an interactive sign-in. They are a hint to the provider. They are never proof. The seam re-reads &lt;code&gt;auth_time&lt;/code&gt; from the presented token on the retry and decides again.&lt;/p&gt;

&lt;h2&gt;
  
  
  The tool registry
&lt;/h2&gt;

&lt;p&gt;The registry holds the policy. Each tool declares a name, a destructive flag, and a window.&lt;/p&gt;

&lt;p&gt;The build enforces an invariant on every write:&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight typescript"&gt;&lt;code&gt;&lt;span class="k"&gt;if &lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="nx"&gt;tool&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nx"&gt;destructive&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="p"&gt;(&lt;/span&gt;&lt;span class="nx"&gt;tool&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nx"&gt;maxAuthAgeSeconds&lt;/span&gt; &lt;span class="o"&gt;===&lt;/span&gt; &lt;span class="kc"&gt;undefined&lt;/span&gt;&lt;span class="p"&gt;)&lt;/span&gt; &lt;span class="p"&gt;{&lt;/span&gt;
    &lt;span class="nx"&gt;violations&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nf"&gt;push&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="s2"&gt;`destructive tool "&lt;/span&gt;&lt;span class="p"&gt;${&lt;/span&gt;&lt;span class="nx"&gt;tool&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nx"&gt;name&lt;/span&gt;&lt;span class="p"&gt;}&lt;/span&gt;&lt;span class="s2"&gt;" has no maxAuthAgeSeconds`&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="k"&gt;if &lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="nx"&gt;tool&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nx"&gt;maxAuthAgeSeconds&lt;/span&gt; &lt;span class="o"&gt;&amp;lt;=&lt;/span&gt; &lt;span class="mi"&gt;0&lt;/span&gt;&lt;span class="p"&gt;)&lt;/span&gt; &lt;span class="p"&gt;{&lt;/span&gt;
    &lt;span class="nx"&gt;violations&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nf"&gt;push&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="s2"&gt;`destructive tool "&lt;/span&gt;&lt;span class="p"&gt;${&lt;/span&gt;&lt;span class="nx"&gt;tool&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nx"&gt;name&lt;/span&gt;&lt;span class="p"&gt;}&lt;/span&gt;&lt;span class="s2"&gt;" has a non-positive window`&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;else&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;tool&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nx"&gt;maxAuthAgeSeconds&lt;/span&gt; &lt;span class="o"&gt;!==&lt;/span&gt; &lt;span class="kc"&gt;undefined&lt;/span&gt;&lt;span class="p"&gt;)&lt;/span&gt; &lt;span class="p"&gt;{&lt;/span&gt;
  &lt;span class="nx"&gt;violations&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nf"&gt;push&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;
    &lt;span class="s2"&gt;`safe tool "&lt;/span&gt;&lt;span class="p"&gt;${&lt;/span&gt;&lt;span class="nx"&gt;tool&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nx"&gt;name&lt;/span&gt;&lt;span class="p"&gt;}&lt;/span&gt;&lt;span class="s2"&gt;" carries maxAuthAgeSeconds; `&lt;/span&gt; &lt;span class="o"&gt;+&lt;/span&gt;
      &lt;span class="s2"&gt;`safe tools must not have a freshness window`&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;Both halves matter. A destructive tool must carry a window, or there is nothing to compare &lt;code&gt;auth_time&lt;/code&gt; against. A safe tool must &lt;strong&gt;not&lt;/strong&gt; carry one. That second rule is approval fatigue written as code: a window on a read-only tool would produce a prompt that never needed to exist, so the registry rejects it.&lt;/p&gt;

&lt;h3&gt;
  
  
  Windows do real work
&lt;/h3&gt;

&lt;p&gt;Two measurements from the build show the windows are not decoration.&lt;/p&gt;

&lt;p&gt;At one instant, on one token, two destructive tools got different answers:&lt;/p&gt;

&lt;div class="table-wrapper-paragraph"&gt;&lt;table&gt;
&lt;thead&gt;
&lt;tr&gt;
&lt;th&gt;Tool&lt;/th&gt;
&lt;th&gt;Window&lt;/th&gt;
&lt;th&gt;Auth age&lt;/th&gt;
&lt;th&gt;Decision&lt;/th&gt;
&lt;/tr&gt;
&lt;/thead&gt;
&lt;tbody&gt;
&lt;tr&gt;
&lt;td&gt;&lt;code&gt;refund_payment&lt;/code&gt;&lt;/td&gt;
&lt;td&gt;120s&lt;/td&gt;
&lt;td&gt;203s&lt;/td&gt;
&lt;td&gt;challenge&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;&lt;code&gt;delete_record&lt;/code&gt;&lt;/td&gt;
&lt;td&gt;300s&lt;/td&gt;
&lt;td&gt;292s&lt;/td&gt;
&lt;td&gt;&lt;strong&gt;allow&lt;/strong&gt;&lt;/td&gt;
&lt;/tr&gt;
&lt;/tbody&gt;
&lt;/table&gt;&lt;/div&gt;

&lt;p&gt;Same person, same session, seconds apart. The refund was held because moving money has a tighter window. The delete passed because a document delete gets more room.&lt;/p&gt;

&lt;p&gt;Then the same tool changed answer with nothing else changing:&lt;/p&gt;

&lt;div class="table-wrapper-paragraph"&gt;&lt;table&gt;
&lt;thead&gt;
&lt;tr&gt;
&lt;th&gt;Tool&lt;/th&gt;
&lt;th&gt;Window&lt;/th&gt;
&lt;th&gt;Auth age&lt;/th&gt;
&lt;th&gt;Decision&lt;/th&gt;
&lt;/tr&gt;
&lt;/thead&gt;
&lt;tbody&gt;
&lt;tr&gt;
&lt;td&gt;&lt;code&gt;delete_record&lt;/code&gt;&lt;/td&gt;
&lt;td&gt;300s&lt;/td&gt;
&lt;td&gt;292s&lt;/td&gt;
&lt;td&gt;allow, executed&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;&lt;code&gt;delete_record&lt;/code&gt;&lt;/td&gt;
&lt;td&gt;300s&lt;/td&gt;
&lt;td&gt;399s&lt;/td&gt;
&lt;td&gt;challenge&lt;/td&gt;
&lt;/tr&gt;
&lt;/tbody&gt;
&lt;/table&gt;&lt;/div&gt;

&lt;p&gt;Time alone closed the window. The window is measured at the moment of the call, every call.&lt;/p&gt;

&lt;h2&gt;
  
  
  The Claude agent loop
&lt;/h2&gt;

&lt;p&gt;The agent holds no credential. It calls the same public endpoint every other client uses, and it forwards the signed-in person's session unchanged:&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight typescript"&gt;&lt;code&gt;&lt;span class="kd"&gt;const&lt;/span&gt; &lt;span class="nx"&gt;response&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="k"&gt;await&lt;/span&gt; &lt;span class="nf"&gt;fetch&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="k"&gt;new&lt;/span&gt; &lt;span class="nc"&gt;URL&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="dl"&gt;"&lt;/span&gt;&lt;span class="s2"&gt;/api/tools/invoke&lt;/span&gt;&lt;span class="dl"&gt;"&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="nf"&gt;appConfig&lt;/span&gt;&lt;span class="p"&gt;().&lt;/span&gt;&lt;span class="nx"&gt;siteUrl&lt;/span&gt;&lt;span class="p"&gt;),&lt;/span&gt; &lt;span class="p"&gt;{&lt;/span&gt;
  &lt;span class="na"&gt;method&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="dl"&gt;"&lt;/span&gt;&lt;span class="s2"&gt;POST&lt;/span&gt;&lt;span class="dl"&gt;"&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt;
  &lt;span class="na"&gt;headers&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="p"&gt;{&lt;/span&gt;
    &lt;span class="dl"&gt;"&lt;/span&gt;&lt;span class="s2"&gt;Content-Type&lt;/span&gt;&lt;span class="dl"&gt;"&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="dl"&gt;"&lt;/span&gt;&lt;span class="s2"&gt;application/json&lt;/span&gt;&lt;span class="dl"&gt;"&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt;
    &lt;span class="c1"&gt;// The person's session, forwarded unchanged. This is the whole of the&lt;/span&gt;
    &lt;span class="c1"&gt;// agent's authority.&lt;/span&gt;
    &lt;span class="na"&gt;cookie&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="nx"&gt;cookieHeader&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt;
  &lt;span class="p"&gt;},&lt;/span&gt;
  &lt;span class="na"&gt;body&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="nx"&gt;tool&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="na"&gt;args&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="nx"&gt;input&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="nx"&gt;correlationId&lt;/span&gt; &lt;span class="p"&gt;}),&lt;/span&gt;
  &lt;span class="na"&gt;cache&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="dl"&gt;"&lt;/span&gt;&lt;span class="s2"&gt;no-store&lt;/span&gt;&lt;span class="dl"&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;The agent never imports the tool executors. If the seam refuses, the agent is refused.&lt;/p&gt;

&lt;p&gt;The tempting shortcut is to give the agent its own API key so it "just works". That shortcut is the failure this design closes. A service account decouples the agent's authority from the person's, and &lt;code&gt;auth_time&lt;/code&gt; becomes meaningless.&lt;/p&gt;

&lt;h3&gt;
  
  
  Three responses, handled differently
&lt;/h3&gt;

&lt;ul&gt;
&lt;li&gt;
&lt;strong&gt;allow&lt;/strong&gt;: run the tool, feed the result back, continue.&lt;/li&gt;
&lt;li&gt;
&lt;strong&gt;challenge&lt;/strong&gt;: stop. Freeze the conversation. Show the person a re-authentication link.&lt;/li&gt;
&lt;li&gt;
&lt;strong&gt;deny&lt;/strong&gt;: stop and report. A denial is not something re-authentication fixes, so the agent offers no link.&lt;/li&gt;
&lt;/ul&gt;

&lt;p&gt;The challenge path is where agents go wrong. &lt;strong&gt;Blind retry&lt;/strong&gt; turns a step-up challenge into a spin, because only the human can clear it. &lt;strong&gt;Tool substitution&lt;/strong&gt; is worse, because it looks like helpfulness: an agent that answers "delete was refused" by reaching for another mutating tool has routed around the control that just fired.&lt;/p&gt;

&lt;p&gt;The build blocks both. The system prompt states the rule:&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight plaintext"&gt;&lt;code&gt;A destructive tool may be held by the server until the person has authenticated
recently. If that happens you will be told so in the tool result, and the run
will end so the person can re-authenticate. That is a normal outcome, not an
error to work around: do not retry a held call, and do not substitute a
different tool to achieve the same effect.
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;The loop makes it structural. A challenge returns out of the loop, so the model never gets another turn in which to try something else.&lt;/p&gt;

&lt;h2&gt;
  
  
  Good manners are not enforcement
&lt;/h2&gt;

&lt;p&gt;Two runs in the build produced the same visible outcome and mean completely different things.&lt;/p&gt;

&lt;p&gt;&lt;strong&gt;Run one.&lt;/strong&gt; The prompt asked the agent to find the superseded document and delete it. The dataset had none, because an earlier run had already deleted it. The agent listed the documents, read them, and refused:&lt;/p&gt;

&lt;blockquote&gt;
&lt;p&gt;I reviewed the documents and did not delete anything, because nothing matches what you asked me to find. Neither document is superseded. Both are status &lt;code&gt;active&lt;/code&gt;, and each summary points the other way — DOC-3301 is the sole copy of a working model, and DOC-3302 is still load-bearing for three open action items.&lt;/p&gt;
&lt;/blockquote&gt;

&lt;p&gt;No destructive tool was called. The audit trail shows only safe calls.&lt;/p&gt;

&lt;p&gt;&lt;strong&gt;Run two.&lt;/strong&gt; Same prompt, record restored. The agent identified the right document, called &lt;code&gt;delete_record&lt;/code&gt;, and the seam refused it with &lt;code&gt;auth_time_stale&lt;/code&gt;.&lt;/p&gt;

&lt;p&gt;Both runs ended with nothing deleted. Only the second is a security control.&lt;/p&gt;

&lt;p&gt;The model &lt;strong&gt;choosing&lt;/strong&gt; not to act is good behaviour. It depends on the prompt, the data, and the model's judgement on the day. It is not a boundary. The &lt;strong&gt;server&lt;/strong&gt; refusing to let it act is enforcement, and it holds regardless of what the model decided or which model you swapped in.&lt;/p&gt;

&lt;p&gt;When you test your own system, force the destructive call to reach the boundary. If it never reaches the seam, you have tested nothing about the seam.&lt;/p&gt;

&lt;h2&gt;
  
  
  The round trip
&lt;/h2&gt;

&lt;p&gt;The full sequence, under one &lt;code&gt;correlationId&lt;/code&gt;:&lt;/p&gt;

&lt;p&gt;&lt;a href="https://media2.dev.to/dynamic/image/width=800%2Cheight=%2Cfit=scale-down%2Cgravity=auto%2Cformat=auto/https%3A%2F%2Fdev-to-uploads.s3.us-east-2.amazonaws.com%2Fuploads%2Farticles%2Fkomyf2wvcmaawfl50ol0.png" class="article-body-image-wrapper"&gt;&lt;img src="https://media2.dev.to/dynamic/image/width=800%2Cheight=%2Cfit=scale-down%2Cgravity=auto%2Cformat=auto/https%3A%2F%2Fdev-to-uploads.s3.us-east-2.amazonaws.com%2Fuploads%2Farticles%2Fkomyf2wvcmaawfl50ol0.png" alt="A sequence between the human, the Claude agent, the seam, Kinde, and the records store. The agent calls list_records over the delegated session and the seam allows it as safe_tool. The agent then calls delete_record. The seam compares an authentication age of 1800 seconds against a 300 second window and answers 403 with insufficient_user_authentication and max_age=300. The agent pauses the run and shows the person a re-authentication link, freezing the tool name, the input, and the tool_use_id. The person signs in at Kinde with max_age=0 and prompt=login, and Kinde returns a new ID token with an advanced auth_time. The person resumes the run. The agent re-presents the same delete_record call. The seam now compares an authentication age of 50 seconds against the same 300 second window and allows it as fresh_authentication. The delete executes and the record changes." width="800" height="689"&gt;&lt;/a&gt;&lt;/p&gt;

&lt;h3&gt;
  
  
  The resume re-presents, it does not re-plan
&lt;/h3&gt;

&lt;p&gt;When the seam holds a call, the build stores the conversation and the exact held call:&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight typescript"&gt;&lt;code&gt;&lt;span class="k"&gt;await&lt;/span&gt; &lt;span class="nf"&gt;convex&lt;/span&gt;&lt;span class="p"&gt;().&lt;/span&gt;&lt;span class="nf"&gt;mutation&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="nx"&gt;api&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nx"&gt;runs&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nx"&gt;pause&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="p"&gt;{&lt;/span&gt;
  &lt;span class="na"&gt;runId&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="nx"&gt;ctx&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nx"&gt;runId&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt;
  &lt;span class="na"&gt;haltedReason&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="nx"&gt;seam&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nx"&gt;body&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nx"&gt;reason&lt;/span&gt; &lt;span class="o"&gt;??&lt;/span&gt; &lt;span class="dl"&gt;"&lt;/span&gt;&lt;span class="s2"&gt;step_up_required&lt;/span&gt;&lt;span class="dl"&gt;"&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt;
  &lt;span class="na"&gt;challengeAuthTime&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="nx"&gt;seam&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nx"&gt;body&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nx"&gt;authTime&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt;
  &lt;span class="na"&gt;pausedState&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="p"&gt;{&lt;/span&gt;
    &lt;span class="nx"&gt;messages&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt;
    &lt;span class="na"&gt;toolUseId&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="nx"&gt;request&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nx"&gt;id&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt;
    &lt;span class="na"&gt;toolName&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="nx"&gt;request&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nx"&gt;name&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt;
    &lt;span class="na"&gt;toolInput&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="nx"&gt;request&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nx"&gt;input&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;On resume, the agent sends that same call to the seam before the model gets another turn:&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight typescript"&gt;&lt;code&gt;&lt;span class="k"&gt;if &lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="nx"&gt;pending&lt;/span&gt; &lt;span class="o"&gt;!==&lt;/span&gt; &lt;span class="kc"&gt;undefined&lt;/span&gt;&lt;span class="p"&gt;)&lt;/span&gt; &lt;span class="p"&gt;{&lt;/span&gt;
  &lt;span class="kd"&gt;const&lt;/span&gt; &lt;span class="nx"&gt;seam&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="k"&gt;await&lt;/span&gt; &lt;span class="nf"&gt;callSeam&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;
    &lt;span class="nx"&gt;pending&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nx"&gt;toolName&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt;
    &lt;span class="nx"&gt;pending&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nx"&gt;toolInput&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt;
    &lt;span class="nx"&gt;ctx&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nx"&gt;correlationId&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt;
    &lt;span class="nx"&gt;ctx&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nx"&gt;cookieHeader&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;This is a security property, not a convenience. If the resume let the model re-plan, a released run would not be the task the person approved. It could become a different, larger action wearing the same &lt;code&gt;correlationId&lt;/code&gt;. Same tool name, same input, same &lt;code&gt;tool_use_id&lt;/code&gt;, or nothing.&lt;/p&gt;

&lt;h3&gt;
  
  
  The resume route decides nothing
&lt;/h3&gt;



&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight typescript"&gt;&lt;code&gt;&lt;span class="nf"&gt;after&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="k"&gt;async &lt;/span&gt;&lt;span class="p"&gt;()&lt;/span&gt; &lt;span class="o"&gt;=&amp;gt;&lt;/span&gt; &lt;span class="p"&gt;{&lt;/span&gt;
  &lt;span class="k"&gt;await&lt;/span&gt; &lt;span class="nf"&gt;resumeAgent&lt;/span&gt;&lt;span class="p"&gt;({&lt;/span&gt; &lt;span class="nx"&gt;runId&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="na"&gt;userId&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="nx"&gt;session&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nx"&gt;subject&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="nx"&gt;cookieHeader&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="nx"&gt;observedAuthTime&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="nx"&gt;NextResponse&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nf"&gt;json&lt;/span&gt;&lt;span class="p"&gt;({&lt;/span&gt; &lt;span class="nx"&gt;runId&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="na"&gt;accepted&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="kc"&gt;true&lt;/span&gt; &lt;span class="p"&gt;},&lt;/span&gt; &lt;span class="p"&gt;{&lt;/span&gt; &lt;span class="na"&gt;status&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="mi"&gt;202&lt;/span&gt; &lt;span class="p"&gt;});&lt;/span&gt;
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;No path through the route releases something the seam would refuse. The &lt;code&gt;auth_time&lt;/code&gt; the route reads serves the audit narrative only, and it comes from a verified token, never from the caller.&lt;/p&gt;

&lt;h2&gt;
  
  
  The two negative cases
&lt;/h2&gt;

&lt;p&gt;A control is only as good as what it refuses. Two refusals carry the whole design.&lt;/p&gt;

&lt;h3&gt;
  
  
  Case 1: a refresh does not release the action
&lt;/h3&gt;

&lt;p&gt;Before any re-authentication, the build ran a &lt;code&gt;refresh_token&lt;/code&gt; grant against the live provider, then retried the held delete.&lt;/p&gt;

&lt;p&gt;The refresh minted a &lt;strong&gt;new, valid&lt;/strong&gt; ID token. The previous one had expired, so this was a real minting, not a cached response.&lt;/p&gt;

&lt;p&gt;The retry was refused.&lt;/p&gt;

&lt;p&gt;The reason code tells the story precisely:&lt;/p&gt;

&lt;div class="table-wrapper-paragraph"&gt;&lt;table&gt;
&lt;thead&gt;
&lt;tr&gt;
&lt;th&gt;Attempt&lt;/th&gt;
&lt;th&gt;Reason&lt;/th&gt;
&lt;th&gt;Auth age&lt;/th&gt;
&lt;th&gt;Window&lt;/th&gt;
&lt;/tr&gt;
&lt;/thead&gt;
&lt;tbody&gt;
&lt;tr&gt;
&lt;td&gt;Original halt&lt;/td&gt;
&lt;td&gt;&lt;code&gt;id_token_invalid&lt;/code&gt;&lt;/td&gt;
&lt;td&gt;—&lt;/td&gt;
&lt;td&gt;300s&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;After refresh&lt;/td&gt;
&lt;td&gt;&lt;code&gt;auth_time_stale&lt;/code&gt;&lt;/td&gt;
&lt;td&gt;&lt;strong&gt;4409s&lt;/strong&gt;&lt;/td&gt;
&lt;td&gt;300s&lt;/td&gt;
&lt;/tr&gt;
&lt;/tbody&gt;
&lt;/table&gt;&lt;/div&gt;

&lt;p&gt;The reason moved from &lt;code&gt;id_token_invalid&lt;/code&gt; to &lt;code&gt;auth_time_stale&lt;/code&gt;. That change is the finding:&lt;/p&gt;

&lt;p&gt;&lt;strong&gt;The refresh repaired the token's validity. It did not repair its freshness.&lt;/strong&gt;&lt;/p&gt;

&lt;p&gt;A machine-to-machine exchange restored a well-formed, verifiable, unexpired credential. The destructive action stayed held, because no human had been present. This is the property the whole design rests on, measured rather than assumed.&lt;/p&gt;

&lt;p&gt;If your freshness check reads &lt;code&gt;exp&lt;/code&gt;, this case passes and the action runs.&lt;/p&gt;

&lt;h3&gt;
  
  
  Case 2: re-authentication does not bless the session
&lt;/h3&gt;

&lt;p&gt;The second refusal was not planned. A genuine interactive re-authentication happened. Then about five minutes of other work passed before the retry fired.&lt;/p&gt;

&lt;p&gt;The retry was refused:&lt;/p&gt;

&lt;div class="table-wrapper-paragraph"&gt;&lt;table&gt;
&lt;thead&gt;
&lt;tr&gt;
&lt;th&gt;Attempt&lt;/th&gt;
&lt;th&gt;Reason&lt;/th&gt;
&lt;th&gt;Auth age&lt;/th&gt;
&lt;th&gt;Window&lt;/th&gt;
&lt;/tr&gt;
&lt;/thead&gt;
&lt;tbody&gt;
&lt;tr&gt;
&lt;td&gt;After real re-auth, delayed&lt;/td&gt;
&lt;td&gt;&lt;code&gt;auth_time_stale&lt;/code&gt;&lt;/td&gt;
&lt;td&gt;&lt;strong&gt;352s&lt;/strong&gt;&lt;/td&gt;
&lt;td&gt;300s&lt;/td&gt;
&lt;/tr&gt;
&lt;/tbody&gt;
&lt;/table&gt;&lt;/div&gt;

&lt;p&gt;The person had re-authenticated correctly. The window had closed again.&lt;/p&gt;

&lt;p&gt;This rules out a weaker reading of the control: that re-authenticating grants the session a blessing that later calls ride on. It does not. The window is measured at the moment of each call. A correct re-authentication that has since aged out is refused exactly like one that never happened.&lt;/p&gt;

&lt;h3&gt;
  
  
  The release
&lt;/h3&gt;

&lt;p&gt;The next re-authentication was followed promptly by the retry:&lt;/p&gt;

&lt;div class="table-wrapper-paragraph"&gt;&lt;table&gt;
&lt;thead&gt;
&lt;tr&gt;
&lt;th&gt;Decision&lt;/th&gt;
&lt;th&gt;Reason&lt;/th&gt;
&lt;th&gt;Auth age&lt;/th&gt;
&lt;th&gt;Window&lt;/th&gt;
&lt;/tr&gt;
&lt;/thead&gt;
&lt;tbody&gt;
&lt;tr&gt;
&lt;td&gt;&lt;strong&gt;allow&lt;/strong&gt;&lt;/td&gt;
&lt;td&gt;&lt;code&gt;fresh_authentication&lt;/code&gt;&lt;/td&gt;
&lt;td&gt;&lt;strong&gt;50s&lt;/strong&gt;&lt;/td&gt;
&lt;td&gt;300s&lt;/td&gt;
&lt;/tr&gt;
&lt;/tbody&gt;
&lt;/table&gt;&lt;/div&gt;

&lt;p&gt;The delete executed. The record changed.&lt;/p&gt;

&lt;p&gt;Seven audit rows carry that run: three safe calls, three challenges, one release. All share one &lt;code&gt;correlationId&lt;/code&gt;, so the story stays readable months later.&lt;/p&gt;

&lt;p&gt;&lt;a href="https://media2.dev.to/dynamic/image/width=800%2Cheight=%2Cfit=scale-down%2Cgravity=auto%2Cformat=auto/https%3A%2F%2Fdev-to-uploads.s3.us-east-2.amazonaws.com%2Fuploads%2Farticles%2Fbn7dm4ioto6o7chvtjbt.png" class="article-body-image-wrapper"&gt;&lt;img src="https://media2.dev.to/dynamic/image/width=800%2Cheight=%2Cfit=scale-down%2Cgravity=auto%2Cformat=auto/https%3A%2F%2Fdev-to-uploads.s3.us-east-2.amazonaws.com%2Fuploads%2Farticles%2Fbn7dm4ioto6o7chvtjbt.png" alt="The console holds delete_record. The banner names the tool and reports the authentication age, 26m 20s, against the tool's window of 5m 0s. The escapes counter stays at zero." width="800" height="435"&gt;&lt;/a&gt;&lt;/p&gt;

&lt;p&gt;&lt;a href="https://media2.dev.to/dynamic/image/width=800%2Cheight=%2Cfit=scale-down%2Cgravity=auto%2Cformat=auto/https%3A%2F%2Fdev-to-uploads.s3.us-east-2.amazonaws.com%2Fuploads%2Farticles%2Fjrpnz5cu76q3a6vv9m09.png" class="article-body-image-wrapper"&gt;&lt;img src="https://media2.dev.to/dynamic/image/width=800%2Cheight=%2Cfit=scale-down%2Cgravity=auto%2Cformat=auto/https%3A%2F%2Fdev-to-uploads.s3.us-east-2.amazonaws.com%2Fuploads%2Farticles%2Fjrpnz5cu76q3a6vv9m09.png" alt="The same run, one correlationId, top to bottom. The seam holds delete_record at auth_time_stale, reporting an authentication age of 3h 12m against a window of 5m 0s. The person re-authenticates. The seam then allows the same call at fresh_authentication, age 3s against the same 5m 0s window, and the delete executes. DOC-3303 shows as deleted in the records panel." width="800" height="405"&gt;&lt;/a&gt;&lt;/p&gt;

&lt;h2&gt;
  
  
  Blanket against step-up
&lt;/h2&gt;

&lt;p&gt;The build ships two modes so the failure and the fix run side by side on the same code.&lt;/p&gt;

&lt;p&gt;&lt;code&gt;APPROVAL_MODE&lt;/code&gt; is read from the deploy environment and from nowhere else:&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight typescript"&gt;&lt;code&gt;&lt;span class="k"&gt;export&lt;/span&gt; &lt;span class="kd"&gt;function&lt;/span&gt; &lt;span class="nf"&gt;approvalMode&lt;/span&gt;&lt;span class="p"&gt;():&lt;/span&gt; &lt;span class="nx"&gt;ApprovalMode&lt;/span&gt; &lt;span class="p"&gt;{&lt;/span&gt;
  &lt;span class="kd"&gt;const&lt;/span&gt; &lt;span class="nx"&gt;raw&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="nx"&gt;process&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nx"&gt;env&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nx"&gt;APPROVAL_MODE&lt;/span&gt;&lt;span class="p"&gt;;&lt;/span&gt;
  &lt;span class="k"&gt;if &lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="k"&gt;typeof&lt;/span&gt; &lt;span class="nx"&gt;raw&lt;/span&gt; &lt;span class="o"&gt;!==&lt;/span&gt; &lt;span class="dl"&gt;"&lt;/span&gt;&lt;span class="s2"&gt;string&lt;/span&gt;&lt;span class="dl"&gt;"&lt;/span&gt;&lt;span class="p"&gt;)&lt;/span&gt; &lt;span class="k"&gt;return&lt;/span&gt; &lt;span class="dl"&gt;"&lt;/span&gt;&lt;span class="s2"&gt;step-up&lt;/span&gt;&lt;span class="dl"&gt;"&lt;/span&gt;&lt;span class="p"&gt;;&lt;/span&gt;
  &lt;span class="k"&gt;return&lt;/span&gt; &lt;span class="nx"&gt;raw&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nf"&gt;trim&lt;/span&gt;&lt;span class="p"&gt;().&lt;/span&gt;&lt;span class="nf"&gt;toLowerCase&lt;/span&gt;&lt;span class="p"&gt;()&lt;/span&gt; &lt;span class="o"&gt;===&lt;/span&gt; &lt;span class="dl"&gt;"&lt;/span&gt;&lt;span class="s2"&gt;blanket&lt;/span&gt;&lt;span class="dl"&gt;"&lt;/span&gt; &lt;span class="p"&gt;?&lt;/span&gt; &lt;span class="dl"&gt;"&lt;/span&gt;&lt;span class="s2"&gt;blanket&lt;/span&gt;&lt;span class="dl"&gt;"&lt;/span&gt; &lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="dl"&gt;"&lt;/span&gt;&lt;span class="s2"&gt;step-up&lt;/span&gt;&lt;span class="dl"&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;Three properties are deliberate. The variable has no &lt;code&gt;NEXT_PUBLIC_&lt;/code&gt; prefix, so Next.js never inlines it into the browser bundle. The seam never reads it from a header, query string, cookie, or tool argument, so a caller cannot set it. And only the exact string &lt;code&gt;blanket&lt;/code&gt; selects the permissive mode: unset, empty, misspelled, or hostile values all resolve to &lt;code&gt;step-up&lt;/code&gt;, so a misconfiguration fails towards enforcement.&lt;/p&gt;

&lt;p&gt;Running the same task in each mode, with an authentication about five hours old:&lt;/p&gt;

&lt;div class="table-wrapper-paragraph"&gt;&lt;table&gt;
&lt;thead&gt;
&lt;tr&gt;
&lt;th&gt;Mode&lt;/th&gt;
&lt;th&gt;Tool&lt;/th&gt;
&lt;th&gt;Auth age&lt;/th&gt;
&lt;th&gt;Decision&lt;/th&gt;
&lt;th&gt;Executed&lt;/th&gt;
&lt;/tr&gt;
&lt;/thead&gt;
&lt;tbody&gt;
&lt;tr&gt;
&lt;td&gt;blanket&lt;/td&gt;
&lt;td&gt;&lt;code&gt;refund_payment&lt;/code&gt;&lt;/td&gt;
&lt;td&gt;~5h&lt;/td&gt;
&lt;td&gt;allow (&lt;code&gt;blanket_mode_freshness_skipped&lt;/code&gt;)&lt;/td&gt;
&lt;td&gt;&lt;strong&gt;yes&lt;/strong&gt;&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;blanket&lt;/td&gt;
&lt;td&gt;&lt;code&gt;delete_record&lt;/code&gt;&lt;/td&gt;
&lt;td&gt;~5h&lt;/td&gt;
&lt;td&gt;allow (&lt;code&gt;blanket_mode_freshness_skipped&lt;/code&gt;)&lt;/td&gt;
&lt;td&gt;&lt;strong&gt;yes&lt;/strong&gt;&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;blanket&lt;/td&gt;
&lt;td&gt;&lt;code&gt;deploy_release&lt;/code&gt;&lt;/td&gt;
&lt;td&gt;~5h&lt;/td&gt;
&lt;td&gt;allow (&lt;code&gt;blanket_mode_freshness_skipped&lt;/code&gt;)&lt;/td&gt;
&lt;td&gt;&lt;strong&gt;yes&lt;/strong&gt;&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;step-up&lt;/td&gt;
&lt;td&gt;&lt;code&gt;delete_record&lt;/code&gt;&lt;/td&gt;
&lt;td&gt;~5h&lt;/td&gt;
&lt;td&gt;challenge (&lt;code&gt;auth_time_stale&lt;/code&gt;)&lt;/td&gt;
&lt;td&gt;no&lt;/td&gt;
&lt;/tr&gt;
&lt;/tbody&gt;
&lt;/table&gt;&lt;/div&gt;

&lt;p&gt;In blanket mode the agent refunded an invoice, deleted a document, and deployed a release to production. Same tokens, same tools, same code. Only the deploy environment differed.&lt;/p&gt;

&lt;h3&gt;
  
  
  The number that matters
&lt;/h3&gt;

&lt;p&gt;The headline counter is &lt;code&gt;executedWithoutFreshAuth&lt;/code&gt;. It counts destructive calls the seam allowed while the authentication was outside the tool's window, or allowed with no age recorded at all. Counting the missing case as a failure keeps the metric honest.&lt;/p&gt;

&lt;p&gt;In step-up mode it stays at &lt;strong&gt;0&lt;/strong&gt;. In blanket mode it climbs.&lt;/p&gt;

&lt;p&gt;&lt;a href="https://media2.dev.to/dynamic/image/width=800%2Cheight=%2Cfit=scale-down%2Cgravity=auto%2Cformat=auto/https%3A%2F%2Fdev-to-uploads.s3.us-east-2.amazonaws.com%2Fuploads%2Farticles%2F0tesb27lzz6o5o8blwo1.png" class="article-body-image-wrapper"&gt;&lt;img src="https://media2.dev.to/dynamic/image/width=800%2Cheight=%2Cfit=scale-down%2Cgravity=auto%2Cformat=auto/https%3A%2F%2Fdev-to-uploads.s3.us-east-2.amazonaws.com%2Fuploads%2Farticles%2F0tesb27lzz6o5o8blwo1.png" alt="Step-up mode. The escapes counter reads zero, and DOC-3303 shows as deleted in the live records panel after the released action." width="800" height="861"&gt;&lt;/a&gt;&lt;/p&gt;

&lt;p&gt;&lt;a href="https://media2.dev.to/dynamic/image/width=800%2Cheight=%2Cfit=scale-down%2Cgravity=auto%2Cformat=auto/https%3A%2F%2Fdev-to-uploads.s3.us-east-2.amazonaws.com%2Fuploads%2Farticles%2F9c8o156p3ye8kqvogben.png" class="article-body-image-wrapper"&gt;&lt;img src="https://media2.dev.to/dynamic/image/width=800%2Cheight=%2Cfit=scale-down%2Cgravity=auto%2Cformat=auto/https%3A%2F%2Fdev-to-uploads.s3.us-east-2.amazonaws.com%2Fuploads%2Farticles%2F9c8o156p3ye8kqvogben.png" alt="Blanket mode, same code. The seam allows refund_payment with the reason " width="799" height="430"&gt;&lt;/a&gt;&lt;/p&gt;

&lt;p&gt;One subtlety caught the build's own test. A blanket-mode slip is not automatically an escape. If the person signed in seconds earlier, the check was skipped but nothing escaped a window it would have failed, and the counter correctly stays at zero. The hole only bites when the human is absent. The end-to-end test now ages the session past the window before the blanket task runs, so the escape it measures is real.&lt;/p&gt;

&lt;h2&gt;
  
  
  Production hardening
&lt;/h2&gt;

&lt;p&gt;Three gaps appeared during the build. Each is easy to ship without noticing.&lt;/p&gt;

&lt;h3&gt;
  
  
  The audit row that vanished
&lt;/h3&gt;

&lt;p&gt;The first version failed closed when Convex was unreachable. The tool did not run, but no audit row was written. A refusal happened with no record that it had. A control that silently stops recording looks identical to one that stopped working.&lt;/p&gt;

&lt;p&gt;The audit write now has three stages:&lt;/p&gt;

&lt;div class="table-wrapper-paragraph"&gt;&lt;table&gt;
&lt;thead&gt;
&lt;tr&gt;
&lt;th&gt;Stage&lt;/th&gt;
&lt;th&gt;Result&lt;/th&gt;
&lt;/tr&gt;
&lt;/thead&gt;
&lt;tbody&gt;
&lt;tr&gt;
&lt;td&gt;Convex, retried with backoff&lt;/td&gt;
&lt;td&gt;&lt;code&gt;recorded&lt;/code&gt;&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;Local append-only spool file&lt;/td&gt;
&lt;td&gt;&lt;code&gt;spooled&lt;/code&gt;&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;Neither&lt;/td&gt;
&lt;td&gt;&lt;code&gt;lost&lt;/code&gt;&lt;/td&gt;
&lt;/tr&gt;
&lt;/tbody&gt;
&lt;/table&gt;&lt;/div&gt;

&lt;p&gt;The write never throws, because the seam needs the outcome to decide what to do next:&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight typescript"&gt;&lt;code&gt;&lt;span class="k"&gt;if &lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="nx"&gt;written&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nx"&gt;durability&lt;/span&gt; &lt;span class="o"&gt;===&lt;/span&gt; &lt;span class="dl"&gt;"&lt;/span&gt;&lt;span class="s2"&gt;lost&lt;/span&gt;&lt;span class="dl"&gt;"&lt;/span&gt; &lt;span class="o"&gt;&amp;amp;&amp;amp;&lt;/span&gt; &lt;span class="nx"&gt;decision&lt;/span&gt; &lt;span class="o"&gt;===&lt;/span&gt; &lt;span class="dl"&gt;"&lt;/span&gt;&lt;span class="s2"&gt;allow&lt;/span&gt;&lt;span class="dl"&gt;"&lt;/span&gt;&lt;span class="p"&gt;)&lt;/span&gt; &lt;span class="p"&gt;{&lt;/span&gt;
  &lt;span class="c1"&gt;// Neither the store nor the spool took it. Allowing now would mean an&lt;/span&gt;
  &lt;span class="c1"&gt;// action ran with no evidence anywhere that it was permitted.&lt;/span&gt;
  &lt;span class="nx"&gt;effectiveDecision&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="dl"&gt;"&lt;/span&gt;&lt;span class="s2"&gt;deny&lt;/span&gt;&lt;span class="dl"&gt;"&lt;/span&gt;&lt;span class="p"&gt;;&lt;/span&gt;
  &lt;span class="nx"&gt;effectiveReason&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="dl"&gt;"&lt;/span&gt;&lt;span class="s2"&gt;audit_unavailable&lt;/span&gt;&lt;span class="dl"&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;The audit outcome can only make a decision stricter, never looser.&lt;/p&gt;

&lt;p&gt;Testing this exposed a second hole. With Convex fully unreachable the seam never reached the audit write: the &lt;strong&gt;registry lookup&lt;/strong&gt; threw first, the route returned 500, and no row was written. Same gap, one step earlier. It would have stayed hidden if the outage had only been simulated at the audit call.&lt;/p&gt;

&lt;p&gt;Measured with the sink pointed at an unreachable host:&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight console"&gt;&lt;code&gt;&lt;span class="go"&gt;POST /api/tools/invoke → 403
  decision=deny  reason=registry_unavailable

.audit-spool.jsonl:
  deny  registry_unavailable  delete_record  cid=0020d4ca

GET /api/health:
  {"degraded": true, "pendingRows": 1}
&lt;/span&gt;&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;After the sink returned, one health probe drained it: &lt;code&gt;{"degraded": false, "pendingRows": 0, "replayedNow": 1}&lt;/code&gt;. The refusal happened, was recorded while the store was down, showed as a backlog, and landed in the store on recovery.&lt;/p&gt;

&lt;h3&gt;
  
  
  The success that reported as a failure
&lt;/h3&gt;

&lt;p&gt;A resume once released a destructive action, executed it, wrote its audit row, and then returned &lt;strong&gt;HTTP 500&lt;/strong&gt;. A trailing Convex write had failed after the work was done. The most dangerous thing a console can say about a delete that happened is "it failed".&lt;/p&gt;

&lt;p&gt;Two changes make that impossible. The response precedes the work: both routes validate, return &lt;code&gt;202 Accepted&lt;/code&gt;, and drive the loop in &lt;code&gt;after()&lt;/code&gt;. And the run's own state is authoritative: the console reads status, timeline, records, and counters from Convex by subscription, never from the fetch that started the run.&lt;/p&gt;

&lt;p&gt;The split between the two trails is deliberate. &lt;strong&gt;Timeline writes are best-effort telemetry&lt;/strong&gt;, and losing one costs visibility. &lt;strong&gt;Audit writes are strict and can refuse the call&lt;/strong&gt;, and losing one costs the security record.&lt;/p&gt;

&lt;h3&gt;
  
  
  The state that lied
&lt;/h3&gt;

&lt;p&gt;A resume left the run marked &lt;code&gt;halted&lt;/code&gt; for its whole duration. The console showed "Held" while a resume was in flight.&lt;/p&gt;

&lt;p&gt;The test found it. The end-to-end script waited for the run to settle, read &lt;code&gt;halted&lt;/code&gt; immediately, and asserted against the previous outcome. The load-bearing negative assertion passed without the resume having happened.&lt;/p&gt;

&lt;p&gt;The fix sets the run to &lt;code&gt;running&lt;/code&gt; at the start of a resume, before the seam is consulted. The test now also waits for a &lt;strong&gt;new audit row&lt;/strong&gt; before reading any outcome, because a new row proves the seam ruled.&lt;/p&gt;

&lt;h2&gt;
  
  
  Honest limitations
&lt;/h2&gt;

&lt;p&gt;Two limitations stand. Both are stated in the project README.&lt;/p&gt;

&lt;h3&gt;
  
  
  It proves when, not how
&lt;/h3&gt;

&lt;p&gt;The console proves &lt;strong&gt;when&lt;/strong&gt; a person authenticated. It cannot prove &lt;strong&gt;how&lt;/strong&gt;.&lt;/p&gt;

&lt;p&gt;The Kinde tenant emits no &lt;code&gt;amr&lt;/code&gt; or &lt;code&gt;acr&lt;/code&gt; claim on either token, even after the person completes multi-factor authentication. This was checked against &lt;a href="https://kinde.com/?utm_source=devto&amp;amp;utm_medium=content&amp;amp;utm_campaign=shola&amp;amp;campaignid=chatgptapp&amp;amp;network=&amp;amp;adgroup=&amp;amp;keyword=&amp;amp;matchtype=&amp;amp;creative=22&amp;amp;device=&amp;amp;adposition=" rel="noopener noreferrer"&gt;Kinde's documentation&lt;/a&gt; and against real tokens from a real MFA sign-in.&lt;/p&gt;

&lt;p&gt;The ID token carries 19 claims:&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight plaintext"&gt;&lt;code&gt;at_hash, aud, auth_time, azp, email, email_verified, exp, family_name,
given_name, iat, iss, jti, name, nonce, org_codes, picture, rat, sub,
updated_at
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;No &lt;code&gt;amr&lt;/code&gt;. No &lt;code&gt;acr&lt;/code&gt;. The access token carries none either.&lt;/p&gt;

&lt;p&gt;To be sure this was the provider's behaviour and not a gap in the build's own claim handling, the session view reports the claim &lt;strong&gt;names&lt;/strong&gt; on each verified token. A claim that is sent but never read looks identical to a claim that is never sent, unless you check.&lt;/p&gt;

&lt;p&gt;Kinde does support custom properties in tokens. The build deliberately did &lt;strong&gt;not&lt;/strong&gt; use them here. A custom property is a static value configured against a user. It is not a record of what happened during a sign-in. A property reading &lt;code&gt;"mfa"&lt;/code&gt; would be emitted on a password-only login exactly as on an MFA login. That is not a weaker control. It is a false one, and it would read to an auditor as though the second factor had been verified.&lt;/p&gt;

&lt;p&gt;The assertion mechanism ships anyway, and ships &lt;strong&gt;off&lt;/strong&gt;:&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight typescript"&gt;&lt;code&gt;&lt;span class="k"&gt;export&lt;/span&gt; &lt;span class="kd"&gt;function&lt;/span&gt; &lt;span class="nf"&gt;requiredAuthMethods&lt;/span&gt;&lt;span class="p"&gt;():&lt;/span&gt; &lt;span class="kr"&gt;string&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="nf"&gt;optional&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="dl"&gt;"&lt;/span&gt;&lt;span class="s2"&gt;STEP_UP_REQUIRED_AMR&lt;/span&gt;&lt;span class="dl"&gt;"&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="dl"&gt;""&lt;/span&gt;&lt;span class="p"&gt;)&lt;/span&gt;
    &lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nf"&gt;split&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="dl"&gt;"&lt;/span&gt;&lt;span class="s2"&gt;,&lt;/span&gt;&lt;span class="dl"&gt;"&lt;/span&gt;&lt;span class="p"&gt;)&lt;/span&gt;
    &lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nf"&gt;map&lt;/span&gt;&lt;span class="p"&gt;((&lt;/span&gt;&lt;span class="nx"&gt;value&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;value&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nf"&gt;trim&lt;/span&gt;&lt;span class="p"&gt;().&lt;/span&gt;&lt;span class="nf"&gt;toLowerCase&lt;/span&gt;&lt;span class="p"&gt;())&lt;/span&gt;
    &lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nf"&gt;filter&lt;/span&gt;&lt;span class="p"&gt;((&lt;/span&gt;&lt;span class="nx"&gt;value&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;value&lt;/span&gt; &lt;span class="o"&gt;!==&lt;/span&gt; &lt;span class="dl"&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;Set &lt;code&gt;STEP_UP_REQUIRED_AMR=mfa&lt;/code&gt; and a destructive release must evidence that method, or the seam holds it with &lt;code&gt;amr_unprovable&lt;/code&gt; or &lt;code&gt;mfa_required&lt;/code&gt;. On this tenant that setting refuses every destructive call, because there is no &lt;code&gt;amr&lt;/code&gt; to check. The refusal is correct fail-closed behaviour, but it is not a control this provider can satisfy, so the flag stays empty.&lt;/p&gt;

&lt;p&gt;For the same reason &lt;code&gt;acr_values&lt;/code&gt; never appears in the challenge header. Demanding an authentication context you cannot verify is a promise you cannot keep.&lt;/p&gt;

&lt;h3&gt;
  
  
  The audit spool is a floor
&lt;/h3&gt;

&lt;p&gt;If the audit store is unreachable, a decision is retried, then written to a local append-only file, then replayed when the store returns. A decision is never &lt;em&gt;silently&lt;/em&gt; lost.&lt;/p&gt;

&lt;p&gt;But that file lives on one host and does not survive it. It is the floor, not a durable-queue story. A production deployment wants a queue with its own availability guarantees.&lt;/p&gt;

&lt;h2&gt;
  
  
  Proving the check can fail
&lt;/h2&gt;

&lt;p&gt;A test that cannot fail is decoration.&lt;/p&gt;

&lt;p&gt;One script walks the whole story in a single pass: &lt;code&gt;npm run e2e&lt;/code&gt;. It runs 45 assertions across seven steps, and every assertion reads the deployment back rather than the HTTP status.&lt;/p&gt;

&lt;p&gt;Two properties are worth copying. &lt;strong&gt;It never sets &lt;code&gt;APPROVAL_MODE&lt;/code&gt; on a request&lt;/strong&gt;: the mode belongs to the deployment, so switching it means restarting the server. A request-level override would have been easier and would have destroyed the invariant the script exists to prove. &lt;strong&gt;It never mints a session&lt;/strong&gt;: it drives a real browser, and the operator signs in once and re-authenticates once.&lt;/p&gt;

&lt;p&gt;A passing run:&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight plaintext"&gt;&lt;code&gt;── Step 1: Clean slate                escapes start at 0
── Step 2: Step-up, read-only         every call allowed, nothing challenged,
                                      no record touched
── Step 3: Blanket, the hole          refund executed, record changed,
                                      escapes → 1
── Step 4: Step-up, the halt          deploy held, nothing executed
── Step 5: The negative               refresh did NOT advance auth_time;
                                      resume refused and recorded
── Step 6: The release                auth_time advanced; released exactly
                                      once at fresh_authentication
── Step 7: Reconciliation             one correlationId; release did NOT
                                      count as an escape

final counters: safe=1 destructive=3 challenged=2 escapes=0
PASS — 45 assertions across 7 steps.
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;Then the important part. Inverting the load-bearing assertion, so it claims a refresh &lt;em&gt;should&lt;/em&gt; advance &lt;code&gt;auth_time&lt;/code&gt;:&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight plaintext"&gt;&lt;code&gt;   ✗ ASSERTION FAILED
   the refresh did NOT advance auth_time
     expected true, got false

FAIL — stopped after 27 passing assertions.
EXIT CODE: 1
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;It stops at the first failure, names the assertion, prints expected against actual, and exits non-zero.&lt;/p&gt;

&lt;p&gt;Run that experiment on your own suite. If you cannot make a security test go red on demand, you do not know what it is testing.&lt;/p&gt;

&lt;h2&gt;
  
  
  How to map this to your app
&lt;/h2&gt;

&lt;p&gt;The identity provider, the database, and the model are replaceable. Six decisions are not.&lt;/p&gt;

&lt;p&gt;&lt;strong&gt;1. Classify your tools, and be strict about the safe ones.&lt;/strong&gt; Split tools into read-only and destructive. Give every destructive tool a window in seconds, tiered by how hard the action is to undo. Forbid windows on safe tools. Enforce both halves in code.&lt;/p&gt;

&lt;p&gt;&lt;strong&gt;2. Read &lt;code&gt;auth_time&lt;/code&gt; from wherever your provider puts it.&lt;/strong&gt; Decode a real token first. On Kinde it sits on the ID token, and the discovery document does not list it. Verify the token that carries authorization and the token that carries freshness, then bind them by &lt;code&gt;sub&lt;/code&gt;.&lt;/p&gt;

&lt;p&gt;&lt;strong&gt;3. Never use &lt;code&gt;iat&lt;/code&gt; or &lt;code&gt;exp&lt;/code&gt; as a freshness proxy.&lt;/strong&gt; Test this against your own provider. Let an ID token expire, run a refresh, and compare &lt;code&gt;auth_time&lt;/code&gt; before and after. If &lt;code&gt;auth_time&lt;/code&gt; moves, you need a different primitive.&lt;/p&gt;

&lt;p&gt;&lt;strong&gt;4. Put the check in one place.&lt;/strong&gt; One function, on the server, at the moment of the call. Not in the client. Not in the prompt. Not spread across six tool implementations.&lt;/p&gt;

&lt;p&gt;&lt;strong&gt;5. Fail closed, and record before you answer.&lt;/strong&gt; A missing window, an unverifiable token, an unreadable registry, an unrecordable decision: all refuse. Write the audit row before you return the decision.&lt;/p&gt;

&lt;p&gt;&lt;strong&gt;6. Handle the challenge properly in the agent.&lt;/strong&gt; Stop the run. Do not retry. Do not substitute another tool. Store the exact held call and re-present it after re-authentication.&lt;/p&gt;

&lt;p&gt;Then test the negatives. Anyone can show a release after a successful sign-in. The design is only worth something if a refresh-only retry stays blocked, and if a correct re-authentication that has aged out is refused too.&lt;/p&gt;

&lt;h2&gt;
  
  
  Resources
&lt;/h2&gt;

&lt;ul&gt;
&lt;li&gt;
&lt;a href="https://www.anthropic.com/engineering/how-we-contain-claude" rel="noopener noreferrer"&gt;How we contain Claude across products&lt;/a&gt;. Anthropic engineering. Source of the ~93% approval figure and the containment approach.&lt;/li&gt;
&lt;li&gt;
&lt;a href="https://www.rfc-editor.org/rfc/rfc9470.html" rel="noopener noreferrer"&gt;RFC 9470: OAuth 2.0 Step Up Authentication Challenge Protocol&lt;/a&gt;. Defines &lt;code&gt;insufficient_user_authentication&lt;/code&gt; and the &lt;code&gt;max_age&lt;/code&gt; parameter.&lt;/li&gt;
&lt;li&gt;
&lt;a href="https://openid.net/specs/openid-connect-core-1_0.html" rel="noopener noreferrer"&gt;OpenID Connect Core 1.0&lt;/a&gt;. Defines &lt;code&gt;auth_time&lt;/code&gt;, &lt;code&gt;max_age&lt;/code&gt;, and &lt;code&gt;prompt=login&lt;/code&gt;.&lt;/li&gt;
&lt;li&gt;
&lt;a href="https://docs.kinde.com/" rel="noopener noreferrer"&gt;Kinde documentation&lt;/a&gt;. ID tokens, token customization, and multi-factor authentication.&lt;/li&gt;
&lt;li&gt;
&lt;a href="https://platform.claude.com/docs/en/agents-and-tools/tool-use/overview" rel="noopener noreferrer"&gt;Anthropic API tool use&lt;/a&gt;. The tool-calling loop the agent runs.&lt;/li&gt;
&lt;li&gt;
&lt;a href="https://docs.convex.dev/" rel="noopener noreferrer"&gt;Convex&lt;/a&gt;. The reactive database behind the registry, records, timeline, and audit trail.&lt;/li&gt;
&lt;/ul&gt;

</description>
      <category>kinde</category>
      <category>ai</category>
      <category>claude</category>
      <category>agents</category>
    </item>
    <item>
      <title>How to Give Every Kimi K3 Agent Its Own Tenant Identity With Kinde Organizations</title>
      <dc:creator>Shola Jegede</dc:creator>
      <pubDate>Sat, 08 Aug 2026 23:36:57 +0000</pubDate>
      <link>https://dev.to/sholajegede/how-to-give-every-kimi-k3-agent-its-own-tenant-identity-with-kinde-organizations-h3f</link>
      <guid>https://dev.to/sholajegede/how-to-give-every-kimi-k3-agent-its-own-tenant-identity-with-kinde-organizations-h3f</guid>
      <description>&lt;p&gt;I built a swarm of Kimi K3 agents for three customers. I gave every agent the same login, because that is the quickest way to get a swarm working. Then I read what one of the agents wrote back:&lt;/p&gt;

&lt;blockquote&gt;
&lt;p&gt;Collected invoice-001 from all three organizations: Tenant A: 1,200.00; Tenant B: 9,900.00; Tenant C: 450.00. The consolidated total is 11,550.00.&lt;/p&gt;
&lt;/blockquote&gt;

&lt;p&gt;Three customers, three invoice amounts, one agent. That agent worked for the first customer and read the other two. Nobody asked it to. The token it carried belonged to no customer in particular, so the server had no reason to refuse it.&lt;/p&gt;

&lt;p&gt;Moonshot's Agent Swarm runs up to 300 sub-agents at once, and one shared credential gives every one of them the reach of your whole platform. That is the multi-tenant problem, and a swarm makes it worse.&lt;/p&gt;

&lt;p&gt;In this tutorial, you'll fix it. You'll give each agent an identity that belongs to one customer, using &lt;a href="https://kinde.com/?utm_source=devto&amp;amp;utm_medium=content&amp;amp;utm_campaign=shola&amp;amp;campaignid=chatgptapp&amp;amp;network=&amp;amp;adgroup=&amp;amp;keyword=&amp;amp;matchtype=&amp;amp;creative=21&amp;amp;device=&amp;amp;adposition=" rel="noopener noreferrer"&gt;Kinde organizations&lt;/a&gt;, and you'll check that identity on every call the agent makes. You'll also build a kill switch that stops one customer's swarm while the others keep working, and you'll find out why the obvious way to build that kill switch does nothing at all.&lt;/p&gt;

&lt;p&gt;By the end, you'll have built:&lt;/p&gt;

&lt;ul&gt;
&lt;li&gt;&lt;p&gt;A Kimi K3 swarm you orchestrate yourself, so each agent's identity is yours to set&lt;/p&gt;&lt;/li&gt;
&lt;li&gt;&lt;p&gt;One Kinde organization for each tenant, and one machine-to-machine application for each agent role inside it&lt;/p&gt;&lt;/li&gt;
&lt;li&gt;&lt;p&gt;A client-credentials flow that mints a scoped token for each agent before it acts&lt;/p&gt;&lt;/li&gt;
&lt;li&gt;&lt;p&gt;A single enforcement point that decides every call, and refuses cross-tenant reads with &lt;code&gt;403 cross_org&lt;/code&gt;&lt;/p&gt;&lt;/li&gt;
&lt;li&gt;&lt;p&gt;A test that proves the boundary holds, by breaking it on purpose&lt;/p&gt;&lt;/li&gt;
&lt;li&gt;&lt;p&gt;A kill switch that stops one tenant mid-run, with the audit trail to show it&lt;/p&gt;&lt;/li&gt;
&lt;li&gt;&lt;p&gt;A console that streams every agent step live, so you can watch a leak happen and then watch it stop&lt;/p&gt;&lt;/li&gt;
&lt;/ul&gt;

&lt;p&gt;Everything here runs. The code is open, and one command replays the whole story.&lt;/p&gt;

&lt;h2&gt;
  
  
  Table of Contents
&lt;/h2&gt;

&lt;ul&gt;
&lt;li&gt;What You'll Build&lt;/li&gt;
&lt;li&gt;Prerequisites&lt;/li&gt;
&lt;li&gt;What a Swarm Changes&lt;/li&gt;
&lt;li&gt;
How the Demo Is Structured

&lt;ul&gt;
&lt;li&gt;The two modes&lt;/li&gt;
&lt;li&gt;Why you orchestrate the swarm yourself&lt;/li&gt;
&lt;/ul&gt;
&lt;/li&gt;
&lt;li&gt;Why One Shared Identity Is the Whole Problem&lt;/li&gt;
&lt;li&gt;How Tenant Isolation Works With Kinde Organizations&lt;/li&gt;
&lt;li&gt;How to Model Each Tenant as a Kinde Organization&lt;/li&gt;
&lt;li&gt;How to Give Each Agent Role Its Own M2M Application&lt;/li&gt;
&lt;li&gt;
How to Mint an Org-Scoped Token Inside Each Agent

&lt;ul&gt;
&lt;li&gt;Check the claim before you build the rest&lt;/li&gt;
&lt;li&gt;Checkpoint&lt;/li&gt;
&lt;/ul&gt;
&lt;/li&gt;
&lt;li&gt;
How to Enforce Isolation at One Point in Your API

&lt;ul&gt;
&lt;li&gt;The rule, as a pure function&lt;/li&gt;
&lt;li&gt;Checking the token&lt;/li&gt;
&lt;li&gt;The seam itself&lt;/li&gt;
&lt;li&gt;Four rules that keep it holding&lt;/li&gt;
&lt;li&gt;Checkpoint&lt;/li&gt;
&lt;/ul&gt;
&lt;/li&gt;
&lt;li&gt;
How to Prove the Boundary Actually Holds

&lt;ul&gt;
&lt;li&gt;Break the check on purpose&lt;/li&gt;
&lt;li&gt;Test the token the same way&lt;/li&gt;
&lt;li&gt;Checkpoint&lt;/li&gt;
&lt;/ul&gt;
&lt;/li&gt;
&lt;li&gt;
How to Build a Kill Switch That Works

&lt;ul&gt;
&lt;li&gt;The assumption, and what measuring it showed&lt;/li&gt;
&lt;li&gt;Where the check has to go&lt;/li&gt;
&lt;li&gt;The suspend action&lt;/li&gt;
&lt;li&gt;One side effect to design for&lt;/li&gt;
&lt;li&gt;Checkpoint&lt;/li&gt;
&lt;/ul&gt;
&lt;/li&gt;
&lt;li&gt;How to Watch It Happen&lt;/li&gt;
&lt;li&gt;What Least Privilege Actually Costs&lt;/li&gt;
&lt;li&gt;Three More Things That Broke&lt;/li&gt;
&lt;li&gt;How to Bring This Up to Production Standard&lt;/li&gt;
&lt;li&gt;How This Maps to Your Own App&lt;/li&gt;
&lt;li&gt;Limits of This Build&lt;/li&gt;
&lt;li&gt;Wrapping Up&lt;/li&gt;
&lt;li&gt;Resources&lt;/li&gt;
&lt;/ul&gt;

&lt;h2&gt;
  
  
  What You'll Build
&lt;/h2&gt;

&lt;p&gt;The demo runs a swarm for three tenants and shows the same call succeed and fail, depending on one server setting.&lt;/p&gt;

&lt;p&gt;In &lt;code&gt;shared&lt;/code&gt; mode, an agent working for Tenant A reads Tenant B's records. The escapes counter turns red, and the timeline names the organization it crossed into:&lt;/p&gt;

&lt;p&gt;&lt;a href="https://media2.dev.to/dynamic/image/width=800%2Cheight=%2Cfit=scale-down%2Cgravity=auto%2Cformat=auto/https%3A%2F%2Fdev-to-uploads.s3.us-east-2.amazonaws.com%2Fuploads%2Farticles%2Fl2rjtwmrv59k1qbwfx5b.png" class="article-body-image-wrapper"&gt;&lt;img src="https://media2.dev.to/dynamic/image/width=800%2Cheight=%2Cfit=scale-down%2Cgravity=auto%2Cformat=auto/https%3A%2F%2Fdev-to-uploads.s3.us-east-2.amazonaws.com%2Fuploads%2Farticles%2Fl2rjtwmrv59k1qbwfx5b.png" alt="SCREENSHOT: console in shared mode. The escapes counter reads 2 and turns red. The timeline line reads " width="800" height="810"&gt;&lt;/a&gt;&lt;/p&gt;

&lt;p&gt;In &lt;code&gt;per-org&lt;/code&gt; mode, the same swarm makes the same call for a different tenant, and the server refuses it. Cross-org attempts stay at 2, blocked rises to 2, escapes stays at 0:&lt;/p&gt;

&lt;p&gt;&lt;a href="https://media2.dev.to/dynamic/image/width=800%2Cheight=%2Cfit=scale-down%2Cgravity=auto%2Cformat=auto/https%3A%2F%2Fdev-to-uploads.s3.us-east-2.amazonaws.com%2Fuploads%2Farticles%2Faz5bho18iyqbsziu1918.png" class="article-body-image-wrapper"&gt;&lt;img src="https://media2.dev.to/dynamic/image/width=800%2Cheight=%2Cfit=scale-down%2Cgravity=auto%2Cformat=auto/https%3A%2F%2Fdev-to-uploads.s3.us-east-2.amazonaws.com%2Fuploads%2Farticles%2Faz5bho18iyqbsziu1918.png" alt="SCREENSHOT: console in per-org mode, on a second tenant. Cross-org attempts 2, blocked 2, escapes 0. The timeline shows " width="800" height="810"&gt;&lt;/a&gt;&lt;/p&gt;

&lt;p&gt;Press the kill switch mid-run and that tenant stops where it stands, while the others carry on:&lt;/p&gt;

&lt;p&gt;&lt;a href="https://media2.dev.to/dynamic/image/width=800%2Cheight=%2Cfit=scale-down%2Cgravity=auto%2Cformat=auto/https%3A%2F%2Fdev-to-uploads.s3.us-east-2.amazonaws.com%2Fuploads%2Farticles%2Ftn5p5scugr7to1nczzb5.png" class="article-body-image-wrapper"&gt;&lt;img src="https://media2.dev.to/dynamic/image/width=800%2Cheight=%2Cfit=scale-down%2Cgravity=auto%2Cformat=auto/https%3A%2F%2Fdev-to-uploads.s3.us-east-2.amazonaws.com%2Fuploads%2Farticles%2Ftn5p5scugr7to1nczzb5.png" alt="SCREENSHOT: the console after the kill switch, showing the tenant marked suspended, the blocked counter at 21, and the banner that explains the stopped timeline." width="800" height="810"&gt;&lt;/a&gt;&lt;/p&gt;

&lt;p&gt;The agents are real, and they decide their own actions. Nobody scripts the reach across a tenant boundary.&lt;/p&gt;

&lt;h2&gt;
  
  
  Prerequisites
&lt;/h2&gt;

&lt;p&gt;To follow along, you'll need:&lt;/p&gt;

&lt;ul&gt;
&lt;li&gt;&lt;p&gt;A Kinde account. The free tier covers everything here.&lt;/p&gt;&lt;/li&gt;
&lt;li&gt;&lt;p&gt;Kimi K3 access, through the Moonshot API or OpenRouter.&lt;/p&gt;&lt;/li&gt;
&lt;li&gt;&lt;p&gt;Node 22+ and pnpm, plus Python 3.11+ for the swarm service.&lt;/p&gt;&lt;/li&gt;
&lt;li&gt;&lt;p&gt;Comfort with OAuth2 client credentials and JWTs. You don't need to be an expert, but you should know what a claim is.&lt;/p&gt;&lt;/li&gt;
&lt;/ul&gt;

&lt;p&gt;The demo uses Convex for the backend and Next.js for the console. Neither is required for the pattern. The enforcement point is about forty lines, and it moves to Express, Hono, or FastAPI without changing shape.&lt;/p&gt;

&lt;h2&gt;
  
  
  What a Swarm Changes
&lt;/h2&gt;

&lt;p&gt;A swarm is a group of AI agents that work at the same time on one job. One agent plans, other agents do the work in parallel, and each one calls tools.&lt;/p&gt;

&lt;p&gt;Two properties make a swarm different from a single agent.&lt;/p&gt;

&lt;p&gt;&lt;strong&gt;Speed.&lt;/strong&gt; Those 300 sub-agents make over 4,000 tool calls in one task, about 4.5 times faster than a single agent working in sequence. Agent Swarm now runs on Kimi K3, which Moonshot calls K3 Swarm (&lt;a href="https://www.kimi.com/help/agent/agent-swarm" rel="noopener noreferrer"&gt;Kimi Help Center&lt;/a&gt;). Nobody reviews that many calls while they happen, so the boundary has to hold on its own.&lt;/p&gt;

&lt;p&gt;&lt;strong&gt;Autonomy.&lt;/strong&gt; The agents choose their own actions. A swarm decides which tools to call, and with which arguments.&lt;/p&gt;

&lt;p&gt;Add customers, and a third property appears.&lt;/p&gt;

&lt;p&gt;&lt;strong&gt;Reach.&lt;/strong&gt; Each agent needs access to data. If every agent uses one credential, that credential holds the sum of all access, and the reach of one agent becomes the reach of the whole system.&lt;/p&gt;

&lt;p&gt;Call that reach the blast radius: the data an agent can touch if it goes wrong. Under one shared login, the blast radius of every agent is every customer.&lt;/p&gt;

&lt;p&gt;Two things make an agent go wrong, and neither needs an attacker:&lt;/p&gt;

&lt;ul&gt;
&lt;li&gt;&lt;p&gt;A prompt injection reaches the agent through data it reads.&lt;/p&gt;&lt;/li&gt;
&lt;li&gt;&lt;p&gt;The planner hands a broad tool to the wrong agent.&lt;/p&gt;&lt;/li&gt;
&lt;/ul&gt;

&lt;p&gt;The second case is the common one. It looks like a bug, not an attack.&lt;/p&gt;

&lt;p&gt;These terms come up throughout:&lt;/p&gt;

&lt;div class="table-wrapper-paragraph"&gt;&lt;table&gt;
&lt;thead&gt;
&lt;tr&gt;
&lt;th&gt;Term&lt;/th&gt;
&lt;th&gt;Meaning&lt;/th&gt;
&lt;/tr&gt;
&lt;/thead&gt;
&lt;tbody&gt;
&lt;tr&gt;
&lt;td&gt;Tenant&lt;/td&gt;
&lt;td&gt;One customer of your platform.&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;Agent&lt;/td&gt;
&lt;td&gt;One AI worker in the swarm. The code calls them workers.&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;Organization&lt;/td&gt;
&lt;td&gt;A Kinde container that holds one tenant.&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;M2M application&lt;/td&gt;
&lt;td&gt;A Kinde login for software, not for a person.&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;Token&lt;/td&gt;
&lt;td&gt;A signed pass that proves who is calling.&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;Scope&lt;/td&gt;
&lt;td&gt;One permission on a token, such as &lt;code&gt;resource:read&lt;/code&gt;.&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;Seam&lt;/td&gt;
&lt;td&gt;The single server function that decides every call.&lt;/td&gt;
&lt;/tr&gt;
&lt;/tbody&gt;
&lt;/table&gt;&lt;/div&gt;

&lt;h2&gt;
  
  
  How the Demo Is Structured
&lt;/h2&gt;

&lt;p&gt;Three services do three jobs:&lt;/p&gt;

&lt;ul&gt;
&lt;li&gt;&lt;p&gt;&lt;strong&gt;Kimi K3&lt;/strong&gt;, served by Moonshot, drives the agents.&lt;/p&gt;&lt;/li&gt;
&lt;li&gt;&lt;p&gt;&lt;strong&gt;Kinde&lt;/strong&gt; proves which tenant each agent belongs to.&lt;/p&gt;&lt;/li&gt;
&lt;li&gt;&lt;p&gt;&lt;strong&gt;Convex&lt;/strong&gt; stores the data, decides every call, and streams each step to the browser.&lt;/p&gt;&lt;/li&gt;
&lt;/ul&gt;

&lt;p&gt;In short: Kimi K3 decides, Kinde proves, Convex enforces and records.&lt;/p&gt;

&lt;p&gt;Every agent call follows the same path, and no agent ever reaches the database:&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight plaintext"&gt;&lt;code&gt;Kimi K3 agent
     │  1. client credentials
     ▼
   Kinde ──→ token { org_code, scope }
     │
     │  2. HTTP tool call, bearer token
     ▼
 Tool endpoint
     │
     ▼
  The seam  ──→ check signature (Kinde JWKS)
     │      ──→ tenant suspended?
     │      ──→ org_code vs record owner
     │      ──→ scope
     ▼
  allow or 403  ──→ audit row  ──→ live timeline
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;The demo runs three tenants. Each holds four records, one of them confidential. The swarm has three agents: two read, one writes.&lt;/p&gt;

&lt;h3&gt;
  
  
  The two modes
&lt;/h3&gt;

&lt;p&gt;The server holds the mode, and no agent or browser can set it:&lt;/p&gt;

&lt;div class="table-wrapper-paragraph"&gt;&lt;table&gt;
&lt;thead&gt;
&lt;tr&gt;
&lt;th&gt;Mode&lt;/th&gt;
&lt;th&gt;Agent identity&lt;/th&gt;
&lt;th&gt;Cross-tenant call&lt;/th&gt;
&lt;/tr&gt;
&lt;/thead&gt;
&lt;tbody&gt;
&lt;tr&gt;
&lt;td&gt;&lt;code&gt;shared&lt;/code&gt;&lt;/td&gt;
&lt;td&gt;One credential for the whole swarm&lt;/td&gt;
&lt;td&gt;The server permits it. The data leaks.&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;&lt;code&gt;per-org&lt;/code&gt;&lt;/td&gt;
&lt;td&gt;One credential for each tenant&lt;/td&gt;
&lt;td&gt;The server refuses it, &lt;code&gt;403 cross_org&lt;/code&gt;.&lt;/td&gt;
&lt;/tr&gt;
&lt;/tbody&gt;
&lt;/table&gt;&lt;/div&gt;

&lt;p&gt;Any value that is not exactly &lt;code&gt;shared&lt;/code&gt; resolves to &lt;code&gt;per-org&lt;/code&gt;. A typo lands in the safe mode, not the leaky one.&lt;/p&gt;

&lt;h3&gt;
  
  
  Why you orchestrate the swarm yourself
&lt;/h3&gt;

&lt;p&gt;Kimi K3 offers a hosted swarm product. This build does not use it, for one reason: a hosted swarm gives no place to inject a different credential for each agent. The identity is the whole security boundary, so the identity has to be yours to set.&lt;/p&gt;

&lt;p&gt;So the orchestrator is your code. It calls the Kimi K3 model, splits the goal, and runs three agents, each holding its own credentials:&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;# swarm/orchestrator.py
&lt;/span&gt;&lt;span class="n"&gt;workers&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="p"&gt;[&lt;/span&gt;
    &lt;span class="nc"&gt;Worker&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="n"&gt;label&lt;/span&gt;&lt;span class="o"&gt;=&lt;/span&gt;&lt;span class="sh"&gt;"&lt;/span&gt;&lt;span class="s"&gt;reader-1&lt;/span&gt;&lt;span class="sh"&gt;"&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="n"&gt;identity&lt;/span&gt;&lt;span class="o"&gt;=&lt;/span&gt;&lt;span class="nc"&gt;WorkerIdentity&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="n"&gt;tenant&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="sh"&gt;"&lt;/span&gt;&lt;span class="s"&gt;READER&lt;/span&gt;&lt;span class="sh"&gt;"&lt;/span&gt;&lt;span class="p"&gt;),&lt;/span&gt;
           &lt;span class="n"&gt;backend&lt;/span&gt;&lt;span class="o"&gt;=&lt;/span&gt;&lt;span class="nc"&gt;Backend&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="n"&gt;correlation_id&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="sh"&gt;"&lt;/span&gt;&lt;span class="s"&gt;reader-1&lt;/span&gt;&lt;span class="sh"&gt;"&lt;/span&gt;&lt;span class="p"&gt;),&lt;/span&gt; &lt;span class="n"&gt;can_write&lt;/span&gt;&lt;span class="o"&gt;=&lt;/span&gt;&lt;span class="bp"&gt;False&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="p"&gt;...),&lt;/span&gt;
    &lt;span class="nc"&gt;Worker&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="n"&gt;label&lt;/span&gt;&lt;span class="o"&gt;=&lt;/span&gt;&lt;span class="sh"&gt;"&lt;/span&gt;&lt;span class="s"&gt;reader-2&lt;/span&gt;&lt;span class="sh"&gt;"&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="n"&gt;identity&lt;/span&gt;&lt;span class="o"&gt;=&lt;/span&gt;&lt;span class="nc"&gt;WorkerIdentity&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="n"&gt;tenant&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="sh"&gt;"&lt;/span&gt;&lt;span class="s"&gt;READER&lt;/span&gt;&lt;span class="sh"&gt;"&lt;/span&gt;&lt;span class="p"&gt;),&lt;/span&gt;
           &lt;span class="n"&gt;backend&lt;/span&gt;&lt;span class="o"&gt;=&lt;/span&gt;&lt;span class="nc"&gt;Backend&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="n"&gt;correlation_id&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="sh"&gt;"&lt;/span&gt;&lt;span class="s"&gt;reader-2&lt;/span&gt;&lt;span class="sh"&gt;"&lt;/span&gt;&lt;span class="p"&gt;),&lt;/span&gt; &lt;span class="n"&gt;can_write&lt;/span&gt;&lt;span class="o"&gt;=&lt;/span&gt;&lt;span class="bp"&gt;False&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="p"&gt;...),&lt;/span&gt;
    &lt;span class="nc"&gt;Worker&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="n"&gt;label&lt;/span&gt;&lt;span class="o"&gt;=&lt;/span&gt;&lt;span class="sh"&gt;"&lt;/span&gt;&lt;span class="s"&gt;writer-1&lt;/span&gt;&lt;span class="sh"&gt;"&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="n"&gt;identity&lt;/span&gt;&lt;span class="o"&gt;=&lt;/span&gt;&lt;span class="nc"&gt;WorkerIdentity&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="n"&gt;tenant&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="sh"&gt;"&lt;/span&gt;&lt;span class="s"&gt;WRITER&lt;/span&gt;&lt;span class="sh"&gt;"&lt;/span&gt;&lt;span class="p"&gt;),&lt;/span&gt;
           &lt;span class="n"&gt;backend&lt;/span&gt;&lt;span class="o"&gt;=&lt;/span&gt;&lt;span class="nc"&gt;Backend&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="n"&gt;correlation_id&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="sh"&gt;"&lt;/span&gt;&lt;span class="s"&gt;writer-1&lt;/span&gt;&lt;span class="sh"&gt;"&lt;/span&gt;&lt;span class="p"&gt;),&lt;/span&gt; &lt;span class="n"&gt;can_write&lt;/span&gt;&lt;span class="o"&gt;=&lt;/span&gt;&lt;span class="bp"&gt;True&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;Each agent runs a normal tool-calling loop. The model picks the tool and the arguments, and your code executes the call:&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;# swarm/worker.py
&lt;/span&gt;&lt;span class="k"&gt;for&lt;/span&gt; &lt;span class="n"&gt;_&lt;/span&gt; &lt;span class="ow"&gt;in&lt;/span&gt; &lt;span class="nf"&gt;range&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="n"&gt;MAX_TURNS&lt;/span&gt;&lt;span class="p"&gt;):&lt;/span&gt;
    &lt;span class="n"&gt;body&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="n"&gt;self&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="n"&gt;kimi&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nf"&gt;chat&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="n"&gt;messages&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="n"&gt;tools&lt;/span&gt;&lt;span class="o"&gt;=&lt;/span&gt;&lt;span class="n"&gt;self&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="n"&gt;message&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="n"&gt;body&lt;/span&gt;&lt;span class="p"&gt;[&lt;/span&gt;&lt;span class="sh"&gt;"&lt;/span&gt;&lt;span class="s"&gt;choices&lt;/span&gt;&lt;span class="sh"&gt;"&lt;/span&gt;&lt;span class="p"&gt;][&lt;/span&gt;&lt;span class="mi"&gt;0&lt;/span&gt;&lt;span class="p"&gt;][&lt;/span&gt;&lt;span class="sh"&gt;"&lt;/span&gt;&lt;span class="s"&gt;message&lt;/span&gt;&lt;span class="sh"&gt;"&lt;/span&gt;&lt;span class="p"&gt;]&lt;/span&gt;
    &lt;span class="n"&gt;messages&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nf"&gt;append&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;tool_calls&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="nf"&gt;get&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="sh"&gt;"&lt;/span&gt;&lt;span class="s"&gt;tool_calls&lt;/span&gt;&lt;span class="sh"&gt;"&lt;/span&gt;&lt;span class="p"&gt;)&lt;/span&gt; &lt;span class="ow"&gt;or&lt;/span&gt; &lt;span class="p"&gt;[]&lt;/span&gt;
    &lt;span class="k"&gt;if&lt;/span&gt; &lt;span class="ow"&gt;not&lt;/span&gt; &lt;span class="n"&gt;tool_calls&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt;
        &lt;span class="n"&gt;summary&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="nf"&gt;get&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="sh"&gt;"&lt;/span&gt;&lt;span class="s"&gt;content&lt;/span&gt;&lt;span class="sh"&gt;"&lt;/span&gt;&lt;span class="p"&gt;)&lt;/span&gt; &lt;span class="ow"&gt;or&lt;/span&gt; &lt;span class="sh"&gt;""&lt;/span&gt;&lt;span class="p"&gt;).&lt;/span&gt;&lt;span class="nf"&gt;strip&lt;/span&gt;&lt;span class="p"&gt;()&lt;/span&gt;
        &lt;span class="k"&gt;break&lt;/span&gt;

    &lt;span class="k"&gt;for&lt;/span&gt; &lt;span class="n"&gt;call&lt;/span&gt; &lt;span class="ow"&gt;in&lt;/span&gt; &lt;span class="n"&gt;tool_calls&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="n"&gt;call&lt;/span&gt;&lt;span class="p"&gt;[&lt;/span&gt;&lt;span class="sh"&gt;"&lt;/span&gt;&lt;span class="s"&gt;function&lt;/span&gt;&lt;span class="sh"&gt;"&lt;/span&gt;&lt;span class="p"&gt;][&lt;/span&gt;&lt;span class="sh"&gt;"&lt;/span&gt;&lt;span class="s"&gt;name&lt;/span&gt;&lt;span class="sh"&gt;"&lt;/span&gt;&lt;span class="p"&gt;]&lt;/span&gt;
        &lt;span class="n"&gt;args&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="n"&gt;json&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nf"&gt;loads&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="n"&gt;call&lt;/span&gt;&lt;span class="p"&gt;[&lt;/span&gt;&lt;span class="sh"&gt;"&lt;/span&gt;&lt;span class="s"&gt;function&lt;/span&gt;&lt;span class="sh"&gt;"&lt;/span&gt;&lt;span class="p"&gt;].&lt;/span&gt;&lt;span class="nf"&gt;get&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="sh"&gt;"&lt;/span&gt;&lt;span class="s"&gt;arguments&lt;/span&gt;&lt;span class="sh"&gt;"&lt;/span&gt;&lt;span class="p"&gt;)&lt;/span&gt; &lt;span class="ow"&gt;or&lt;/span&gt; &lt;span class="sh"&gt;"&lt;/span&gt;&lt;span class="s"&gt;{}&lt;/span&gt;&lt;span class="sh"&gt;"&lt;/span&gt;&lt;span class="p"&gt;)&lt;/span&gt;
        &lt;span class="n"&gt;result&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="n"&gt;self&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nf"&gt;_execute&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="n"&gt;name&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="c1"&gt;# HTTP, with this agent's token
&lt;/span&gt;        &lt;span class="n"&gt;self&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nf"&gt;_record&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="n"&gt;name&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;result&lt;/span&gt;&lt;span class="p"&gt;)&lt;/span&gt;     &lt;span class="c1"&gt;# streams to the live timeline
&lt;/span&gt;        &lt;span class="n"&gt;messages&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nf"&gt;append&lt;/span&gt;&lt;span class="p"&gt;({&lt;/span&gt;&lt;span class="sh"&gt;"&lt;/span&gt;&lt;span class="s"&gt;role&lt;/span&gt;&lt;span class="sh"&gt;"&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="sh"&gt;"&lt;/span&gt;&lt;span class="s"&gt;tool&lt;/span&gt;&lt;span class="sh"&gt;"&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="sh"&gt;"&lt;/span&gt;&lt;span class="s"&gt;tool_call_id&lt;/span&gt;&lt;span class="sh"&gt;"&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="n"&gt;call&lt;/span&gt;&lt;span class="p"&gt;[&lt;/span&gt;&lt;span class="sh"&gt;"&lt;/span&gt;&lt;span class="s"&gt;id&lt;/span&gt;&lt;span class="sh"&gt;"&lt;/span&gt;&lt;span class="p"&gt;],&lt;/span&gt;
                         &lt;span class="sh"&gt;"&lt;/span&gt;&lt;span class="s"&gt;content&lt;/span&gt;&lt;span class="sh"&gt;"&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="n"&gt;json&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nf"&gt;dumps&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="n"&gt;result&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="n"&gt;body&lt;/span&gt;&lt;span class="p"&gt;)[:&lt;/span&gt;&lt;span class="mi"&gt;1500&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;read_resource&lt;/code&gt; takes an optional organization code:&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="p"&gt;{&lt;/span&gt;
    &lt;span class="sh"&gt;"&lt;/span&gt;&lt;span class="s"&gt;type&lt;/span&gt;&lt;span class="sh"&gt;"&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="sh"&gt;"&lt;/span&gt;&lt;span class="s"&gt;function&lt;/span&gt;&lt;span class="sh"&gt;"&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt;
    &lt;span class="sh"&gt;"&lt;/span&gt;&lt;span class="s"&gt;function&lt;/span&gt;&lt;span class="sh"&gt;"&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="p"&gt;{&lt;/span&gt;
        &lt;span class="sh"&gt;"&lt;/span&gt;&lt;span class="s"&gt;name&lt;/span&gt;&lt;span class="sh"&gt;"&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="sh"&gt;"&lt;/span&gt;&lt;span class="s"&gt;read_resource&lt;/span&gt;&lt;span class="sh"&gt;"&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt;
        &lt;span class="sh"&gt;"&lt;/span&gt;&lt;span class="s"&gt;description&lt;/span&gt;&lt;span class="sh"&gt;"&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="sh"&gt;"&lt;/span&gt;&lt;span class="s"&gt;Read one record by key, or by id.&lt;/span&gt;&lt;span class="sh"&gt;"&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt;
        &lt;span class="sh"&gt;"&lt;/span&gt;&lt;span class="s"&gt;parameters&lt;/span&gt;&lt;span class="sh"&gt;"&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="p"&gt;{&lt;/span&gt;&lt;span class="sh"&gt;"&lt;/span&gt;&lt;span class="s"&gt;type&lt;/span&gt;&lt;span class="sh"&gt;"&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="sh"&gt;"&lt;/span&gt;&lt;span class="s"&gt;object&lt;/span&gt;&lt;span class="sh"&gt;"&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="sh"&gt;"&lt;/span&gt;&lt;span class="s"&gt;properties&lt;/span&gt;&lt;span class="sh"&gt;"&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="p"&gt;{&lt;/span&gt;
            &lt;span class="sh"&gt;"&lt;/span&gt;&lt;span class="s"&gt;key&lt;/span&gt;&lt;span class="sh"&gt;"&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="p"&gt;{&lt;/span&gt;&lt;span class="sh"&gt;"&lt;/span&gt;&lt;span class="s"&gt;type&lt;/span&gt;&lt;span class="sh"&gt;"&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="sh"&gt;"&lt;/span&gt;&lt;span class="s"&gt;string&lt;/span&gt;&lt;span class="sh"&gt;"&lt;/span&gt;&lt;span class="p"&gt;},&lt;/span&gt;
            &lt;span class="sh"&gt;"&lt;/span&gt;&lt;span class="s"&gt;resource_id&lt;/span&gt;&lt;span class="sh"&gt;"&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="p"&gt;{&lt;/span&gt;&lt;span class="sh"&gt;"&lt;/span&gt;&lt;span class="s"&gt;type&lt;/span&gt;&lt;span class="sh"&gt;"&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="sh"&gt;"&lt;/span&gt;&lt;span class="s"&gt;string&lt;/span&gt;&lt;span class="sh"&gt;"&lt;/span&gt;&lt;span class="p"&gt;},&lt;/span&gt;
            &lt;span class="sh"&gt;"&lt;/span&gt;&lt;span class="s"&gt;target_org_code&lt;/span&gt;&lt;span class="sh"&gt;"&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="p"&gt;{&lt;/span&gt;&lt;span class="sh"&gt;"&lt;/span&gt;&lt;span class="s"&gt;type&lt;/span&gt;&lt;span class="sh"&gt;"&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="sh"&gt;"&lt;/span&gt;&lt;span class="s"&gt;string&lt;/span&gt;&lt;span class="sh"&gt;"&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="sh"&gt;"&lt;/span&gt;&lt;span class="s"&gt;description&lt;/span&gt;&lt;span class="sh"&gt;"&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt;
                &lt;span class="sh"&gt;"&lt;/span&gt;&lt;span class="s"&gt;Optional. The organization that owns the record. &lt;/span&gt;&lt;span class="sh"&gt;"&lt;/span&gt;
                &lt;span class="sh"&gt;"&lt;/span&gt;&lt;span class="s"&gt;Defaults to your own organization.&lt;/span&gt;&lt;span class="sh"&gt;"&lt;/span&gt;&lt;span class="p"&gt;},&lt;/span&gt;
        &lt;span class="p"&gt;},&lt;/span&gt; &lt;span class="sh"&gt;"&lt;/span&gt;&lt;span class="s"&gt;required&lt;/span&gt;&lt;span class="sh"&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;span class="p"&gt;}&lt;/span&gt;
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;Platform software knows which tenants exist, and an agent doing a platform-wide job needs a way to name one. Naming a tenant grants nothing. The server still decides.&lt;/p&gt;

&lt;p&gt;At this point every agent shares one identity.&lt;/p&gt;

&lt;h2&gt;
  
  
  Why One Shared Identity Is the Whole Problem
&lt;/h2&gt;

&lt;p&gt;Your API sees one thing when an agent calls it. It sees a token. It does not see which agent sent the token, what the agent intended, or what the planner asked for. The token is the entire security boundary.&lt;/p&gt;

&lt;p&gt;A shared identity does not weaken that boundary. It removes it.&lt;/p&gt;

&lt;p&gt;One M2M application for the swarm means one set of scopes, and those scopes have to cover everything any agent might need. The union of all needs becomes the floor of every agent's access.&lt;/p&gt;

&lt;p&gt;&lt;a href="https://media2.dev.to/dynamic/image/width=800%2Cheight=%2Cfit=scale-down%2Cgravity=auto%2Cformat=auto/https%3A%2F%2Fdev-to-uploads.s3.us-east-2.amazonaws.com%2Fuploads%2Farticles%2Frx2gszt8wywt7fybxjne.png" class="article-body-image-wrapper"&gt;&lt;img src="https://media2.dev.to/dynamic/image/width=800%2Cheight=%2Cfit=scale-down%2Cgravity=auto%2Cformat=auto/https%3A%2F%2Fdev-to-uploads.s3.us-east-2.amazonaws.com%2Fuploads%2Farticles%2Frx2gszt8wywt7fybxjne.png" alt="A Kimi K3 orchestrator directs three agents, reader-1, reader-2 and writer-1. All three converge on a single shared M2M token that carries no org_code and every scope. That token reaches one tool endpoint, which in turn reaches three separate data stores: Tenant A, Tenant B and Tenant C. A dashed line marked " width="800" height="217"&gt;&lt;/a&gt;&lt;/p&gt;

&lt;p&gt;One credential, with no tenant on it. Nothing refuses, and the reach of one agent is the reach of the whole platform.&lt;/p&gt;

&lt;h2&gt;
  
  
  How Tenant Isolation Works With Kinde Organizations
&lt;/h2&gt;

&lt;p&gt;The pattern has three parts:&lt;/p&gt;

&lt;ol&gt;
&lt;li&gt;&lt;p&gt;One Kinde organization holds one tenant.&lt;/p&gt;&lt;/li&gt;
&lt;li&gt;&lt;p&gt;One M2M application holds one agent role inside one organization.&lt;/p&gt;&lt;/li&gt;
&lt;li&gt;&lt;p&gt;The token that application receives carries an &lt;code&gt;org_code&lt;/code&gt; claim and a scope list.&lt;/p&gt;&lt;/li&gt;
&lt;/ol&gt;

&lt;p&gt;Your API then compares two values on every call: the &lt;code&gt;org_code&lt;/code&gt; on the token, and the tenant that owns the record. A mismatch is a refusal.&lt;/p&gt;

&lt;p&gt;The agent never states its own tenant. It presents a token that Kinde signed, and it cannot edit a claim without breaking the signature.&lt;/p&gt;

&lt;p&gt;&lt;a href="https://media2.dev.to/dynamic/image/width=800%2Cheight=%2Cfit=scale-down%2Cgravity=auto%2Cformat=auto/https%3A%2F%2Fdev-to-uploads.s3.us-east-2.amazonaws.com%2Fuploads%2Farticles%2F8jgwu4drilk7hgebwj20.png" class="article-body-image-wrapper"&gt;&lt;img src="https://media2.dev.to/dynamic/image/width=800%2Cheight=%2Cfit=scale-down%2Cgravity=auto%2Cformat=auto/https%3A%2F%2Fdev-to-uploads.s3.us-east-2.amazonaws.com%2Fuploads%2Farticles%2F8jgwu4drilk7hgebwj20.png" alt="The same three agents now each carry a token scoped to Tenant A. The two readers share a token holding org_code A and resource:read. The writer holds a separate token with org_code A and resource:write. Both tokens pass into one decision point that compares the org_code on the token against the tenant that owns the record. A match reaches Tenant A's records. A mismatch returns 403 cross_org and writes an audit row. Dashed lines marked " width="800" height="149"&gt;&lt;/a&gt;&lt;/p&gt;

&lt;p&gt;The same failure now stops at one tenant, and the server records the attempt.&lt;/p&gt;

&lt;p&gt;&lt;a href="https://kinde.com/?utm_source=devto&amp;amp;utm_medium=content&amp;amp;utm_campaign=shola&amp;amp;campaignid=chatgptapp&amp;amp;network=&amp;amp;adgroup=&amp;amp;keyword=&amp;amp;matchtype=&amp;amp;creative=21&amp;amp;device=&amp;amp;adposition=" rel="noopener noreferrer"&gt;Kinde organizations&lt;/a&gt; are flat, with no nesting. One organization for one tenant keeps the model simple, and the check stays a single comparison.&lt;/p&gt;

&lt;h2&gt;
  
  
  How to Model Each Tenant as a Kinde Organization
&lt;/h2&gt;

&lt;p&gt;Create one organization for each tenant. Kinde gives each one a code, such as &lt;code&gt;org_2606b8199462b&lt;/code&gt;. The code is an identifier, not a secret.&lt;/p&gt;

&lt;p&gt;&lt;a href="https://media2.dev.to/dynamic/image/width=800%2Cheight=%2Cfit=scale-down%2Cgravity=auto%2Cformat=auto/https%3A%2F%2Fdev-to-uploads.s3.us-east-2.amazonaws.com%2Fuploads%2Farticles%2Fveant7vw4mj8n11yk43z.png" class="article-body-image-wrapper"&gt;&lt;img src="https://media2.dev.to/dynamic/image/width=800%2Cheight=%2Cfit=scale-down%2Cgravity=auto%2Cformat=auto/https%3A%2F%2Fdev-to-uploads.s3.us-east-2.amazonaws.com%2Fuploads%2Farticles%2Fveant7vw4mj8n11yk43z.png" alt="SCREENSHOT: Kinde dashboard, Organizations list, showing Tenant A, Tenant B and Tenant C with their codes" width="800" height="460"&gt;&lt;/a&gt;&lt;/p&gt;

&lt;p&gt;Create organizations on your server, through the Management API, when a customer signs up. Do not accept an organization code from a browser. The code in an authorization URL can be changed by the person using the browser, so treat it as a request, never as proof.&lt;/p&gt;

&lt;p&gt;The demo uses three organizations:&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight plaintext"&gt;&lt;code&gt;Tenant A   org_2606b8199462b
Tenant B   org_364dd8200a3d3
Tenant C   org_0c39cb2010b01
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;h2&gt;
  
  
  How to Give Each Agent Role Its Own M2M Application
&lt;/h2&gt;

&lt;p&gt;Create one M2M application for each agent role, inside each tenant's organization. The demo has two roles, so it has six applications:&lt;/p&gt;

&lt;div class="table-wrapper-paragraph"&gt;&lt;table&gt;
&lt;thead&gt;
&lt;tr&gt;
&lt;th&gt;Organization&lt;/th&gt;
&lt;th&gt;Application&lt;/th&gt;
&lt;th&gt;Scope&lt;/th&gt;
&lt;/tr&gt;
&lt;/thead&gt;
&lt;tbody&gt;
&lt;tr&gt;
&lt;td&gt;Tenant A&lt;/td&gt;
&lt;td&gt;Tenant A Reader&lt;/td&gt;
&lt;td&gt;&lt;code&gt;resource:read&lt;/code&gt;&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;Tenant A&lt;/td&gt;
&lt;td&gt;Tenant A Writer&lt;/td&gt;
&lt;td&gt;&lt;code&gt;resource:write&lt;/code&gt;&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;Tenant B&lt;/td&gt;
&lt;td&gt;Tenant B Reader&lt;/td&gt;
&lt;td&gt;&lt;code&gt;resource:read&lt;/code&gt;&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;Tenant B&lt;/td&gt;
&lt;td&gt;Tenant B Writer&lt;/td&gt;
&lt;td&gt;&lt;code&gt;resource:write&lt;/code&gt;&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;Tenant C&lt;/td&gt;
&lt;td&gt;Tenant C Reader&lt;/td&gt;
&lt;td&gt;&lt;code&gt;resource:read&lt;/code&gt;&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;Tenant C&lt;/td&gt;
&lt;td&gt;Tenant C Writer&lt;/td&gt;
&lt;td&gt;&lt;code&gt;resource:write&lt;/code&gt;&lt;/td&gt;
&lt;/tr&gt;
&lt;/tbody&gt;
&lt;/table&gt;&lt;/div&gt;

&lt;p&gt;Give each application one scope. The read agents cannot write, and the write agent cannot read. Role isolation becomes scopes inside the organization, and tenant isolation becomes the organization itself.&lt;/p&gt;

&lt;p&gt;&lt;a href="https://media2.dev.to/dynamic/image/width=800%2Cheight=%2Cfit=scale-down%2Cgravity=auto%2Cformat=auto/https%3A%2F%2Fdev-to-uploads.s3.us-east-2.amazonaws.com%2Fuploads%2Farticles%2F1bqc0muyyvsxwtgov7m3.png" class="article-body-image-wrapper"&gt;&lt;img src="https://media2.dev.to/dynamic/image/width=800%2Cheight=%2Cfit=scale-down%2Cgravity=auto%2Cformat=auto/https%3A%2F%2Fdev-to-uploads.s3.us-east-2.amazonaws.com%2Fuploads%2Farticles%2F1bqc0muyyvsxwtgov7m3.png" alt="SCREENSHOT: Kinde M2M application scopes panel for Tenant A Reader, with resource:read selected and resource:write clear" width="800" height="461"&gt;&lt;/a&gt;&lt;/p&gt;

&lt;p&gt;Create one more application for the Management API, with &lt;code&gt;read:organizations&lt;/code&gt; and &lt;code&gt;update:organizations&lt;/code&gt;, and nothing else. The kill switch uses it.&lt;/p&gt;

&lt;h2&gt;
  
  
  How to Mint an Org-Scoped Token Inside Each Agent
&lt;/h2&gt;

&lt;p&gt;Each agent exchanges its own credentials for a token, using the OAuth2 client-credentials flow. There is no shared credential anywhere in the service:&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;# swarm/identity.py
&lt;/span&gt;&lt;span class="nd"&gt;@dataclass&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="n"&gt;frozen&lt;/span&gt;&lt;span class="o"&gt;=&lt;/span&gt;&lt;span class="bp"&gt;True&lt;/span&gt;&lt;span class="p"&gt;)&lt;/span&gt;
&lt;span class="k"&gt;class&lt;/span&gt; &lt;span class="nc"&gt;WorkerIdentity&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt;
    &lt;span class="n"&gt;tenant&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="nb"&gt;str&lt;/span&gt;   &lt;span class="c1"&gt;# "A", "B", "C"
&lt;/span&gt;    &lt;span class="n"&gt;role&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="nb"&gt;str&lt;/span&gt;     &lt;span class="c1"&gt;# "READER" or "WRITER"
&lt;/span&gt;
    &lt;span class="nd"&gt;@property&lt;/span&gt;
    &lt;span class="k"&gt;def&lt;/span&gt; &lt;span class="nf"&gt;client_id_var&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="n"&gt;self&lt;/span&gt;&lt;span class="p"&gt;)&lt;/span&gt; &lt;span class="o"&gt;-&amp;gt;&lt;/span&gt; &lt;span class="nb"&gt;str&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt;
        &lt;span class="k"&gt;return&lt;/span&gt; &lt;span class="sa"&gt;f&lt;/span&gt;&lt;span class="sh"&gt;"&lt;/span&gt;&lt;span class="s"&gt;KINDE_M2M_TENANT_&lt;/span&gt;&lt;span class="si"&gt;{&lt;/span&gt;&lt;span class="n"&gt;self&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="n"&gt;tenant&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;self&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="n"&gt;role&lt;/span&gt;&lt;span class="si"&gt;}&lt;/span&gt;&lt;span class="s"&gt;_CLIENT_ID&lt;/span&gt;&lt;span class="sh"&gt;"&lt;/span&gt;


&lt;span class="k"&gt;def&lt;/span&gt; &lt;span class="nf"&gt;fetch_token&lt;/span&gt;&lt;span class="p"&gt;(&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;WorkerIdentity&lt;/span&gt;&lt;span class="p"&gt;)&lt;/span&gt; &lt;span class="o"&gt;-&amp;gt;&lt;/span&gt; &lt;span class="nb"&gt;str&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt;
    &lt;span class="n"&gt;response&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="n"&gt;requests&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nf"&gt;post&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;
        &lt;span class="nf"&gt;required&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="sh"&gt;"&lt;/span&gt;&lt;span class="s"&gt;KINDE_M2M_TOKEN_URL&lt;/span&gt;&lt;span class="sh"&gt;"&lt;/span&gt;&lt;span class="p"&gt;),&lt;/span&gt;
        &lt;span class="n"&gt;data&lt;/span&gt;&lt;span class="o"&gt;=&lt;/span&gt;&lt;span class="p"&gt;{&lt;/span&gt;
            &lt;span class="sh"&gt;"&lt;/span&gt;&lt;span class="s"&gt;grant_type&lt;/span&gt;&lt;span class="sh"&gt;"&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="sh"&gt;"&lt;/span&gt;&lt;span class="s"&gt;client_credentials&lt;/span&gt;&lt;span class="sh"&gt;"&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt;
            &lt;span class="sh"&gt;"&lt;/span&gt;&lt;span class="s"&gt;client_id&lt;/span&gt;&lt;span class="sh"&gt;"&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="nf"&gt;required&lt;/span&gt;&lt;span class="p"&gt;(&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;client_id_var&lt;/span&gt;&lt;span class="p"&gt;),&lt;/span&gt;
            &lt;span class="sh"&gt;"&lt;/span&gt;&lt;span class="s"&gt;client_secret&lt;/span&gt;&lt;span class="sh"&gt;"&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="nf"&gt;required&lt;/span&gt;&lt;span class="p"&gt;(&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;client_secret_var&lt;/span&gt;&lt;span class="p"&gt;),&lt;/span&gt;
            &lt;span class="sh"&gt;"&lt;/span&gt;&lt;span class="s"&gt;audience&lt;/span&gt;&lt;span class="sh"&gt;"&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="nf"&gt;required&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="sh"&gt;"&lt;/span&gt;&lt;span class="s"&gt;KINDE_AUDIENCE&lt;/span&gt;&lt;span class="sh"&gt;"&lt;/span&gt;&lt;span class="p"&gt;),&lt;/span&gt;
        &lt;span class="p"&gt;},&lt;/span&gt;
        &lt;span class="n"&gt;timeout&lt;/span&gt;&lt;span class="o"&gt;=&lt;/span&gt;&lt;span class="mi"&gt;30&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;response&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="n"&gt;status_code&lt;/span&gt; &lt;span class="o"&gt;!=&lt;/span&gt; &lt;span class="mi"&gt;200&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt;
        &lt;span class="k"&gt;raise&lt;/span&gt; &lt;span class="nc"&gt;TokenRefused&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="s"&gt;tenant &lt;/span&gt;&lt;span class="si"&gt;{&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;tenant&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;identity&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="n"&gt;role&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="n"&gt;status&lt;/span&gt;&lt;span class="o"&gt;=&lt;/span&gt;&lt;span class="n"&gt;response&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="n"&gt;status_code&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;response&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nf"&gt;json&lt;/span&gt;&lt;span class="p"&gt;()[&lt;/span&gt;&lt;span class="sh"&gt;"&lt;/span&gt;&lt;span class="s"&gt;access_token&lt;/span&gt;&lt;span class="sh"&gt;"&lt;/span&gt;&lt;span class="p"&gt;]&lt;/span&gt;
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;An agent cannot get another tenant's token. The credentials it uses come from the role it was created with, which is a property of the code rather than a rule the agent follows.&lt;/p&gt;

&lt;p&gt;Here is a real token from the demo, decoded. This is the Tenant A read agent:&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;"aud"&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="s2"&gt;"swarm-demo-api"&lt;/span&gt;&lt;span class="p"&gt;],&lt;/span&gt;&lt;span class="w"&gt;
  &lt;/span&gt;&lt;span class="nl"&gt;"azp"&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt;&lt;span class="w"&gt; &lt;/span&gt;&lt;span class="s2"&gt;"89321317d1fe4100925ca8b4ab4b589a"&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt;&lt;span class="w"&gt;
  &lt;/span&gt;&lt;span class="nl"&gt;"exp"&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt;&lt;span class="w"&gt; &lt;/span&gt;&lt;span class="mi"&gt;1786285575&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt;&lt;span class="w"&gt;
  &lt;/span&gt;&lt;span class="nl"&gt;"gty"&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="s2"&gt;"client_credentials"&lt;/span&gt;&lt;span class="p"&gt;],&lt;/span&gt;&lt;span class="w"&gt;
  &lt;/span&gt;&lt;span class="nl"&gt;"iss"&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt;&lt;span class="w"&gt; &lt;/span&gt;&lt;span class="s2"&gt;"https://devrelstudio.kinde.com"&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt;&lt;span class="w"&gt;
  &lt;/span&gt;&lt;span class="nl"&gt;"jti"&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt;&lt;span class="w"&gt; &lt;/span&gt;&lt;span class="s2"&gt;"7d9f74d3-89d4-4d99-a648-ab51210da6ec"&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt;&lt;span class="w"&gt;
  &lt;/span&gt;&lt;span class="nl"&gt;"org_code"&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt;&lt;span class="w"&gt; &lt;/span&gt;&lt;span class="s2"&gt;"org_2606b8199462b"&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt;&lt;span class="w"&gt;
  &lt;/span&gt;&lt;span class="nl"&gt;"scope"&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt;&lt;span class="w"&gt; &lt;/span&gt;&lt;span class="s2"&gt;"resource:read"&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt;&lt;span class="w"&gt;
  &lt;/span&gt;&lt;span class="nl"&gt;"v"&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt;&lt;span class="w"&gt; &lt;/span&gt;&lt;span class="s2"&gt;"2"&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;Two claims matter. &lt;code&gt;org_code&lt;/code&gt; says which tenant, and &lt;code&gt;scope&lt;/code&gt; says which permission.&lt;/p&gt;

&lt;h3&gt;
  
  
  Check the claim before you build the rest
&lt;/h3&gt;

&lt;p&gt;Kinde M2M applications authenticate as an application, not as a person inside an organization, so the &lt;code&gt;org_code&lt;/code&gt; claim is worth confirming before you create twelve of them. Make one application, mint one token, and decode it.&lt;/p&gt;

&lt;p&gt;All six came back correct in this build:&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight plaintext"&gt;&lt;code&gt;TENANT_A_READER  org_2606b8199462b  resource:read
TENANT_A_WRITER  org_2606b8199462b  resource:write
TENANT_B_READER  org_364dd8200a3d3  resource:read
TENANT_B_WRITER  org_364dd8200a3d3  resource:write
TENANT_C_READER  org_0c39cb2010b01  resource:read
TENANT_C_WRITER  org_0c39cb2010b01  resource:write
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;h3&gt;
  
  
  Checkpoint
&lt;/h3&gt;

&lt;p&gt;Mint a token for one application and decode the payload at &lt;code&gt;jwt.io&lt;/code&gt;, or with a two-line script. You should see &lt;code&gt;org_code&lt;/code&gt; set to that application's organization, and &lt;code&gt;scope&lt;/code&gt; holding exactly one permission. If &lt;code&gt;org_code&lt;/code&gt; is missing, the application is not scoped to an organization, and the rest of this build has nothing to check against.&lt;/p&gt;

&lt;h2&gt;
  
  
  How to Enforce Isolation at One Point in Your API
&lt;/h2&gt;

&lt;p&gt;Put the decision in one function. Every agent call passes through it, nothing downstream re-checks, and nothing upstream skips it.&lt;/p&gt;

&lt;h3&gt;
  
  
  The rule, as a pure function
&lt;/h3&gt;

&lt;p&gt;Keep the rule itself separate from the plumbing. This one takes six inputs and returns a decision. It has no network access, no database access, and no token parsing, so it can be tested completely:&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight typescript"&gt;&lt;code&gt;&lt;span class="c1"&gt;// convex/lib/decide.ts&lt;/span&gt;
&lt;span class="k"&gt;export&lt;/span&gt; &lt;span class="kd"&gt;function&lt;/span&gt; &lt;span class="nf"&gt;decide&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="nx"&gt;input&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="nx"&gt;DecisionInput&lt;/span&gt;&lt;span class="p"&gt;):&lt;/span&gt; &lt;span class="nx"&gt;Decision&lt;/span&gt; &lt;span class="p"&gt;{&lt;/span&gt;
  &lt;span class="kd"&gt;const&lt;/span&gt; &lt;span class="nx"&gt;crossOrg&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt;
    &lt;span class="nx"&gt;input&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nx"&gt;targetOrgCode&lt;/span&gt; &lt;span class="o"&gt;!==&lt;/span&gt; &lt;span class="kc"&gt;null&lt;/span&gt; &lt;span class="o"&gt;&amp;amp;&amp;amp;&lt;/span&gt; &lt;span class="nx"&gt;input&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nx"&gt;targetOrgCode&lt;/span&gt; &lt;span class="o"&gt;!==&lt;/span&gt; &lt;span class="nx"&gt;input&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nx"&gt;actorOrgCode&lt;/span&gt;&lt;span class="p"&gt;;&lt;/span&gt;

  &lt;span class="c1"&gt;// Checked before the mode, so it applies in both.&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;input&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nx"&gt;actorSuspended&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="p"&gt;{&lt;/span&gt; &lt;span class="na"&gt;allow&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="kc"&gt;false&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="na"&gt;reason&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="nx"&gt;DENY&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nx"&gt;suspended&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="nx"&gt;crossOrg&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="p"&gt;(&lt;/span&gt;&lt;span class="nx"&gt;input&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nx"&gt;mode&lt;/span&gt; &lt;span class="o"&gt;===&lt;/span&gt; &lt;span class="dl"&gt;"&lt;/span&gt;&lt;span class="s2"&gt;per-org&lt;/span&gt;&lt;span class="dl"&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;if &lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="nx"&gt;crossOrg&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="p"&gt;{&lt;/span&gt; &lt;span class="na"&gt;allow&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="kc"&gt;false&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="na"&gt;reason&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="nx"&gt;DENY&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nx"&gt;crossOrg&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="nx"&gt;crossOrg&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="p"&gt;(&lt;/span&gt;&lt;span class="nx"&gt;input&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nx"&gt;requiredScope&lt;/span&gt; &lt;span class="o"&gt;!==&lt;/span&gt; &lt;span class="kc"&gt;null&lt;/span&gt; &lt;span class="o"&gt;&amp;amp;&amp;amp;&lt;/span&gt;
        &lt;span class="o"&gt;!&lt;/span&gt;&lt;span class="nx"&gt;input&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nx"&gt;actorScopes&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nf"&gt;includes&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="nx"&gt;input&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nx"&gt;requiredScope&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="p"&gt;{&lt;/span&gt; &lt;span class="na"&gt;allow&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="kc"&gt;false&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="na"&gt;reason&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="nx"&gt;DENY&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nx"&gt;insufficientScope&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="nx"&gt;crossOrg&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="p"&gt;{&lt;/span&gt; &lt;span class="na"&gt;allow&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="kc"&gt;true&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="na"&gt;reason&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="nx"&gt;ALLOW&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nx"&gt;ok&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="nx"&gt;crossOrg&lt;/span&gt; &lt;span class="p"&gt;};&lt;/span&gt;
  &lt;span class="p"&gt;}&lt;/span&gt;

  &lt;span class="c1"&gt;// Shared mode: one credential for the whole swarm belongs to no tenant and&lt;/span&gt;
  &lt;span class="c1"&gt;// carries every permission, so neither check has anything to bite on.&lt;/span&gt;
  &lt;span class="k"&gt;return&lt;/span&gt; &lt;span class="p"&gt;{&lt;/span&gt;
    &lt;span class="na"&gt;allow&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="kc"&gt;true&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt;
    &lt;span class="na"&gt;reason&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="nx"&gt;crossOrg&lt;/span&gt; &lt;span class="p"&gt;?&lt;/span&gt; &lt;span class="nx"&gt;ALLOW&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nx"&gt;crossOrgAllowed&lt;/span&gt; &lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="nx"&gt;ALLOW&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nx"&gt;ok&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt;
    &lt;span class="nx"&gt;crossOrg&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;The order matters. The rule checks the tenant first, then the permission. A call that crosses a tenant boundary with the wrong scope reports &lt;code&gt;cross_org&lt;/code&gt;, because the tenant boundary is the more serious failure.&lt;/p&gt;

&lt;h3&gt;
  
  
  Checking the token
&lt;/h3&gt;

&lt;p&gt;Check the token against the Kinde public keys before anything else. Pin the algorithm, and refuse a token that carries no &lt;code&gt;org_code&lt;/code&gt; rather than guessing one:&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight typescript"&gt;&lt;code&gt;&lt;span class="c1"&gt;// convex/lib/kindeToken.ts&lt;/span&gt;
&lt;span class="kd"&gt;const&lt;/span&gt; &lt;span class="p"&gt;{&lt;/span&gt; &lt;span class="nx"&gt;payload&lt;/span&gt; &lt;span class="p"&gt;}&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="k"&gt;await&lt;/span&gt; &lt;span class="nf"&gt;jwtVerify&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="nx"&gt;token&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="nf"&gt;jwksFor&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="nx"&gt;issuer&lt;/span&gt;&lt;span class="p"&gt;),&lt;/span&gt; &lt;span class="p"&gt;{&lt;/span&gt;
  &lt;span class="nx"&gt;issuer&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt;
  &lt;span class="nx"&gt;audience&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt;
  &lt;span class="c1"&gt;// Kinde signs with RS256. Pinning it stops a token arriving with a weaker&lt;/span&gt;
  &lt;span class="c1"&gt;// algorithm chosen by whoever sent it.&lt;/span&gt;
  &lt;span class="na"&gt;algorithms&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="p"&gt;[&lt;/span&gt;&lt;span class="dl"&gt;"&lt;/span&gt;&lt;span class="s2"&gt;RS256&lt;/span&gt;&lt;span class="dl"&gt;"&lt;/span&gt;&lt;span class="p"&gt;],&lt;/span&gt;
&lt;span class="p"&gt;});&lt;/span&gt;

&lt;span class="kd"&gt;const&lt;/span&gt; &lt;span class="nx"&gt;orgCode&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="k"&gt;typeof&lt;/span&gt; &lt;span class="nx"&gt;payload&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nx"&gt;org_code&lt;/span&gt; &lt;span class="o"&gt;===&lt;/span&gt; &lt;span class="dl"&gt;"&lt;/span&gt;&lt;span class="s2"&gt;string&lt;/span&gt;&lt;span class="dl"&gt;"&lt;/span&gt; &lt;span class="p"&gt;?&lt;/span&gt; &lt;span class="nx"&gt;payload&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nx"&gt;org_code&lt;/span&gt; &lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="dl"&gt;""&lt;/span&gt;&lt;span class="p"&gt;;&lt;/span&gt;
&lt;span class="k"&gt;if &lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="o"&gt;!&lt;/span&gt;&lt;span class="nx"&gt;orgCode&lt;/span&gt;&lt;span class="p"&gt;)&lt;/span&gt; &lt;span class="p"&gt;{&lt;/span&gt;
  &lt;span class="k"&gt;throw&lt;/span&gt; &lt;span class="k"&gt;new&lt;/span&gt; &lt;span class="nc"&gt;TokenError&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="nx"&gt;TOKEN_DENY&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nx"&gt;missingOrgCode&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;h3&gt;
  
  
  The seam itself
&lt;/h3&gt;

&lt;p&gt;The seam runs those checks in a fixed order, and every refusal leaves by one path:&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight typescript"&gt;&lt;code&gt;&lt;span class="c1"&gt;// convex/lib/seam.ts&lt;/span&gt;
&lt;span class="k"&gt;export&lt;/span&gt; &lt;span class="k"&gt;async&lt;/span&gt; &lt;span class="kd"&gt;function&lt;/span&gt; &lt;span class="nf"&gt;guard&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="nx"&gt;ctx&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="nx"&gt;request&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="nx"&gt;spec&lt;/span&gt;&lt;span class="p"&gt;):&lt;/span&gt; &lt;span class="nb"&gt;Promise&lt;/span&gt;&lt;span class="o"&gt;&amp;lt;&lt;/span&gt;&lt;span class="nx"&gt;Response&lt;/span&gt;&lt;span class="o"&gt;&amp;gt;&lt;/span&gt; &lt;span class="p"&gt;{&lt;/span&gt;
  &lt;span class="kd"&gt;const&lt;/span&gt; &lt;span class="nx"&gt;correlationId&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt;
    &lt;span class="nx"&gt;request&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nx"&gt;headers&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nf"&gt;get&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="dl"&gt;"&lt;/span&gt;&lt;span class="s2"&gt;x-correlation-id&lt;/span&gt;&lt;span class="dl"&gt;"&lt;/span&gt;&lt;span class="p"&gt;)?.&lt;/span&gt;&lt;span class="nf"&gt;trim&lt;/span&gt;&lt;span class="p"&gt;()&lt;/span&gt; &lt;span class="o"&gt;||&lt;/span&gt; &lt;span class="nx"&gt;crypto&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nf"&gt;randomUUID&lt;/span&gt;&lt;span class="p"&gt;();&lt;/span&gt;

  &lt;span class="c1"&gt;// 1. Who is calling. Nothing below trusts the request body for it.&lt;/span&gt;
  &lt;span class="kd"&gt;let&lt;/span&gt; &lt;span class="na"&gt;identity&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="nx"&gt;VerifiedIdentity&lt;/span&gt;&lt;span class="p"&gt;;&lt;/span&gt;
  &lt;span class="k"&gt;try&lt;/span&gt; &lt;span class="p"&gt;{&lt;/span&gt;
    &lt;span class="nx"&gt;identity&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="k"&gt;await&lt;/span&gt; &lt;span class="nf"&gt;verifyAccessToken&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="nx"&gt;request&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nx"&gt;headers&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nf"&gt;get&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="dl"&gt;"&lt;/span&gt;&lt;span class="s2"&gt;authorization&lt;/span&gt;&lt;span class="dl"&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;catch &lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="nx"&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="nf"&gt;refuse&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="nf"&gt;reasonOf&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="nx"&gt;error&lt;/span&gt;&lt;span class="p"&gt;),&lt;/span&gt; &lt;span class="mi"&gt;401&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="dl"&gt;"&lt;/span&gt;&lt;span class="s2"&gt;unknown&lt;/span&gt;&lt;span class="dl"&gt;"&lt;/span&gt;&lt;span class="p"&gt;);&lt;/span&gt;
  &lt;span class="p"&gt;}&lt;/span&gt;

  &lt;span class="c1"&gt;// 2. Is this tenant allowed to operate at all?&lt;/span&gt;
  &lt;span class="kd"&gt;const&lt;/span&gt; &lt;span class="nx"&gt;actorSuspended&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="k"&gt;await&lt;/span&gt; &lt;span class="nx"&gt;ctx&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nf"&gt;runQuery&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="nx"&gt;internal&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nx"&gt;tenants&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nx"&gt;isSuspended&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="p"&gt;{&lt;/span&gt;
    &lt;span class="na"&gt;orgCode&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="nx"&gt;identity&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nx"&gt;orgCode&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt;
  &lt;span class="p"&gt;});&lt;/span&gt;

  &lt;span class="c1"&gt;// 3. Which tenant owns the record being reached for?&lt;/span&gt;
  &lt;span class="kd"&gt;const&lt;/span&gt; &lt;span class="nx"&gt;target&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="k"&gt;await&lt;/span&gt; &lt;span class="nx"&gt;spec&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nf"&gt;resolveTarget&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="nx"&gt;identity&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="nx"&gt;body&lt;/span&gt;&lt;span class="p"&gt;);&lt;/span&gt;
  &lt;span class="kd"&gt;const&lt;/span&gt; &lt;span class="nx"&gt;targetOrgCode&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="nx"&gt;target&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nx"&gt;kind&lt;/span&gt; &lt;span class="o"&gt;===&lt;/span&gt; &lt;span class="dl"&gt;"&lt;/span&gt;&lt;span class="s2"&gt;record&lt;/span&gt;&lt;span class="dl"&gt;"&lt;/span&gt; &lt;span class="p"&gt;?&lt;/span&gt; &lt;span class="nx"&gt;target&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nx"&gt;doc&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nx"&gt;orgCode&lt;/span&gt; &lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="kc"&gt;null&lt;/span&gt;&lt;span class="p"&gt;;&lt;/span&gt;

  &lt;span class="c1"&gt;// 4. The decision.&lt;/span&gt;
  &lt;span class="kd"&gt;const&lt;/span&gt; &lt;span class="nx"&gt;decision&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="nf"&gt;decide&lt;/span&gt;&lt;span class="p"&gt;({&lt;/span&gt;
    &lt;span class="nx"&gt;mode&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt;
    &lt;span class="na"&gt;actorOrgCode&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="nx"&gt;identity&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nx"&gt;orgCode&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt;
    &lt;span class="na"&gt;actorScopes&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="nx"&gt;identity&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nx"&gt;scopes&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt;
    &lt;span class="na"&gt;requiredScope&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="nx"&gt;spec&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nx"&gt;requiredScope&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt;
    &lt;span class="nx"&gt;targetOrgCode&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt;
    &lt;span class="nx"&gt;actorSuspended&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt;
  &lt;span class="p"&gt;});&lt;/span&gt;

  &lt;span class="c1"&gt;// 5. Written down before the caller is answered, allow or deny.&lt;/span&gt;
  &lt;span class="k"&gt;await&lt;/span&gt; &lt;span class="nf"&gt;write&lt;/span&gt;&lt;span class="p"&gt;({&lt;/span&gt; &lt;span class="na"&gt;decision&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="nx"&gt;decision&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nx"&gt;allow&lt;/span&gt; &lt;span class="p"&gt;?&lt;/span&gt; &lt;span class="dl"&gt;"&lt;/span&gt;&lt;span class="s2"&gt;allow&lt;/span&gt;&lt;span class="dl"&gt;"&lt;/span&gt; &lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="dl"&gt;"&lt;/span&gt;&lt;span class="s2"&gt;deny&lt;/span&gt;&lt;span class="dl"&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;span class="k"&gt;if &lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="o"&gt;!&lt;/span&gt;&lt;span class="nx"&gt;decision&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nx"&gt;allow&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="nf"&gt;json&lt;/span&gt;&lt;span class="p"&gt;({&lt;/span&gt; &lt;span class="na"&gt;ok&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="kc"&gt;false&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="na"&gt;reason&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="nx"&gt;decision&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nx"&gt;reason&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="nx"&gt;correlationId&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt;
                  &lt;span class="na"&gt;isolationMode&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="nx"&gt;mode&lt;/span&gt; &lt;span class="p"&gt;},&lt;/span&gt; &lt;span class="mi"&gt;403&lt;/span&gt;&lt;span class="p"&gt;);&lt;/span&gt;
  &lt;span class="p"&gt;}&lt;/span&gt;

  &lt;span class="c1"&gt;// 6. Only now does the work happen.&lt;/span&gt;
  &lt;span class="kd"&gt;const&lt;/span&gt; &lt;span class="nx"&gt;data&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="k"&gt;await&lt;/span&gt; &lt;span class="nx"&gt;spec&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nf"&gt;perform&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="nx"&gt;target&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="nx"&gt;body&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="nx"&gt;identity&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="nx"&gt;mode&lt;/span&gt;&lt;span class="p"&gt;);&lt;/span&gt;
  &lt;span class="k"&gt;return&lt;/span&gt; &lt;span class="nf"&gt;json&lt;/span&gt;&lt;span class="p"&gt;({&lt;/span&gt; &lt;span class="na"&gt;ok&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="kc"&gt;true&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="nx"&gt;correlationId&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="na"&gt;crossOrg&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="nx"&gt;decision&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nx"&gt;crossOrg&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="p"&gt;...&lt;/span&gt;&lt;span class="nx"&gt;data&lt;/span&gt; &lt;span class="p"&gt;},&lt;/span&gt; &lt;span class="mi"&gt;200&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;&lt;a href="https://media2.dev.to/dynamic/image/width=800%2Cheight=%2Cfit=scale-down%2Cgravity=auto%2Cformat=auto/https%3A%2F%2Fdev-to-uploads.s3.us-east-2.amazonaws.com%2Fuploads%2Farticles%2Fywqrdh2odiwyn6nsamxl.png" class="article-body-image-wrapper"&gt;&lt;img src="https://media2.dev.to/dynamic/image/width=800%2Cheight=%2Cfit=scale-down%2Cgravity=auto%2Cformat=auto/https%3A%2F%2Fdev-to-uploads.s3.us-east-2.amazonaws.com%2Fuploads%2Farticles%2Fywqrdh2odiwyn6nsamxl.png" alt="A single path from an agent's tool call through four checks in fixed order. First, is the signature valid against the Kinde JWKS: no returns 401 invalid_token. Second, is the tenant suspended: yes returns 403 organization_suspended. Third, does the org_code on the token match the record owner: no under per-org returns 403 cross_org, while no under shared permits the call and records it as a breach. Fourth, does the token carry the required scope: no returns 403 insufficient_scope, yes allows the call. All six outcomes, four refusals and two permits, converge on a single audit row." width="800" height="928"&gt;&lt;/a&gt;&lt;/p&gt;

&lt;p&gt;Every path ends in an audit row. The server writes the row before it answers the caller, so a client that disconnects cannot lose a refusal.&lt;/p&gt;

&lt;h3&gt;
  
  
  Four rules that keep it holding
&lt;/h3&gt;

&lt;p&gt;&lt;strong&gt;The acting tenant is never user input.&lt;/strong&gt; It comes out of token verification and passes inward. Every data function is internal, so nothing outside the backend can call in and name a tenant.&lt;/p&gt;

&lt;p&gt;&lt;strong&gt;Agents never reach the database.&lt;/strong&gt; They call HTTP tool endpoints. The request body chooses the record, and the token decides the tenant.&lt;/p&gt;

&lt;p&gt;&lt;strong&gt;Fail closed.&lt;/strong&gt; Any path that cannot complete a check refuses. A tenant the server has never heard of counts as suspended.&lt;/p&gt;

&lt;p&gt;&lt;strong&gt;Record everything.&lt;/strong&gt; Every allow and every refusal writes a row, with a correlation id that ties one run together across every agent.&lt;/p&gt;

&lt;h3&gt;
  
  
  Checkpoint
&lt;/h3&gt;

&lt;p&gt;Call your tool endpoint three times: once with no &lt;code&gt;Authorization&lt;/code&gt; header, once with a valid token for its own record, and once with that same token against another tenant's record. You should get &lt;code&gt;401&lt;/code&gt;, &lt;code&gt;200&lt;/code&gt;, and &lt;code&gt;403 cross_org&lt;/code&gt;. Then read your audit table. All three calls should be there, including the one that failed before it reached any data.&lt;/p&gt;

&lt;h2&gt;
  
  
  How to Prove the Boundary Actually Holds
&lt;/h2&gt;

&lt;p&gt;A passing test suite proves nothing by itself. It proves something when it fails for the right reason.&lt;/p&gt;

&lt;h3&gt;
  
  
  Break the check on purpose
&lt;/h3&gt;

&lt;p&gt;Delete the ownership check from the boundary and run the tests again:&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight typescript"&gt;&lt;code&gt;&lt;span class="c1"&gt;// convex/lib/tenancy.ts, the check under test&lt;/span&gt;
&lt;span class="k"&gt;export&lt;/span&gt; &lt;span class="kd"&gt;function&lt;/span&gt; &lt;span class="nf"&gt;requireSameOrg&lt;/span&gt;&lt;span class="o"&gt;&amp;lt;&lt;/span&gt;&lt;span class="nx"&gt;T&lt;/span&gt; &lt;span class="kd"&gt;extends&lt;/span&gt; &lt;span class="nx"&gt;TenantOwned&lt;/span&gt;&lt;span class="o"&gt;&amp;gt;&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;
  &lt;span class="nx"&gt;actorOrgCode&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="kr"&gt;string&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt;
  &lt;span class="nx"&gt;doc&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="nx"&gt;T&lt;/span&gt; &lt;span class="o"&gt;|&lt;/span&gt; &lt;span class="kc"&gt;null&lt;/span&gt; &lt;span class="o"&gt;|&lt;/span&gt; &lt;span class="kc"&gt;undefined&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt;
&lt;span class="p"&gt;):&lt;/span&gt; &lt;span class="nx"&gt;T&lt;/span&gt; &lt;span class="p"&gt;{&lt;/span&gt;
  &lt;span class="k"&gt;if &lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="o"&gt;!&lt;/span&gt;&lt;span class="nx"&gt;doc&lt;/span&gt;&lt;span class="p"&gt;)&lt;/span&gt; &lt;span class="p"&gt;{&lt;/span&gt;
    &lt;span class="nf"&gt;deny&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="nx"&gt;DENY&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nx"&gt;notFound&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="p"&gt;(&lt;/span&gt;&lt;span class="nx"&gt;doc&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nx"&gt;orgCode&lt;/span&gt; &lt;span class="o"&gt;!==&lt;/span&gt; &lt;span class="nx"&gt;actorOrgCode&lt;/span&gt;&lt;span class="p"&gt;)&lt;/span&gt; &lt;span class="p"&gt;{&lt;/span&gt;   &lt;span class="c1"&gt;// deleted this for the experiment&lt;/span&gt;
    &lt;span class="nf"&gt;deny&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="nx"&gt;DENY&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nx"&gt;crossOrg&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="nx"&gt;doc&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;Exactly three tests failed, and they were the three cross-tenant tests. The other three kept passing. Restoring the check returned six passes.&lt;/p&gt;

&lt;p&gt;A suite that cannot fail when the boundary breaks is decoration.&lt;/p&gt;

&lt;h3&gt;
  
  
  Test the token the same way
&lt;/h3&gt;

&lt;p&gt;Two checks matter more than the rest:&lt;/p&gt;

&lt;ul&gt;
&lt;li&gt;&lt;p&gt;Change one character in a token signature. The server must refuse it.&lt;/p&gt;&lt;/li&gt;
&lt;li&gt;&lt;p&gt;Change the &lt;code&gt;org_code&lt;/code&gt; in the payload to another tenant. The server must refuse it.&lt;/p&gt;&lt;/li&gt;
&lt;/ul&gt;

&lt;p&gt;Both return &lt;code&gt;401 invalid_token&lt;/code&gt; in the demo. If the token check were skipped, both would return &lt;code&gt;200&lt;/code&gt;.&lt;/p&gt;

&lt;h3&gt;
  
  
  Checkpoint
&lt;/h3&gt;

&lt;p&gt;Comment out your own ownership check and run your tests. If they still pass, they are not testing the boundary. Put the check back before you go further.&lt;/p&gt;

&lt;h2&gt;
  
  
  How to Build a Kill Switch That Works
&lt;/h2&gt;

&lt;h3&gt;
  
  
  The assumption, and what measuring it showed
&lt;/h3&gt;

&lt;p&gt;The plan for this build said: suspend the tenant's organization in Kinde, and its agents stop, because their tokens stop working.&lt;/p&gt;

&lt;p&gt;Measuring that first is what saved it:&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight plaintext"&gt;&lt;code&gt;suspend Tenant C   -&amp;gt;  is_suspended: true
token endpoint     -&amp;gt;  HTTP 200      (Kinde still issues M2M tokens)
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;&lt;a href="https://kinde.com/?utm_source=devto&amp;amp;utm_medium=content&amp;amp;utm_campaign=shola&amp;amp;campaignid=chatgptapp&amp;amp;network=&amp;amp;adgroup=&amp;amp;keyword=&amp;amp;matchtype=&amp;amp;creative=21&amp;amp;device=&amp;amp;adposition=" rel="noopener noreferrer"&gt;Kinde organization&lt;/a&gt; suspension governs people who sign in. It does not stop the client-credentials flow for a machine-to-machine application. A token already issued also stays valid until it expires, because nothing recalls a JWT.&lt;/p&gt;

&lt;p&gt;So suspension by itself stops no running agent. A kill switch built on that belief looks correct in a demo, passes a casual test, and stops nothing during a real incident.&lt;/p&gt;

&lt;h3&gt;
  
  
  Where the check has to go
&lt;/h3&gt;

&lt;p&gt;Check suspension on the server, on every call. Look again at the rule earlier in this tutorial: the suspension check sits above the mode check, so it applies in both modes. A kill switch that only worked in the safe mode would be useless, because the leaky mode is exactly when a swarm needs stopping.&lt;/p&gt;

&lt;h3&gt;
  
  
  The suspend action
&lt;/h3&gt;

&lt;p&gt;The kill switch does three things:&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight typescript"&gt;&lt;code&gt;&lt;span class="c1"&gt;// convex/killSwitch.ts&lt;/span&gt;
&lt;span class="k"&gt;export&lt;/span&gt; &lt;span class="kd"&gt;const&lt;/span&gt; &lt;span class="nx"&gt;suspend&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="nf"&gt;internalAction&lt;/span&gt;&lt;span class="p"&gt;({&lt;/span&gt;
  &lt;span class="na"&gt;args&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="p"&gt;{&lt;/span&gt; &lt;span class="na"&gt;orgCode&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="nx"&gt;v&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nf"&gt;string&lt;/span&gt;&lt;span class="p"&gt;()&lt;/span&gt; &lt;span class="p"&gt;},&lt;/span&gt;
  &lt;span class="na"&gt;handler&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="k"&gt;async &lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="nx"&gt;ctx&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="p"&gt;{&lt;/span&gt; &lt;span class="nx"&gt;orgCode&lt;/span&gt; &lt;span class="p"&gt;})&lt;/span&gt; &lt;span class="o"&gt;=&amp;gt;&lt;/span&gt; &lt;span class="p"&gt;{&lt;/span&gt;
    &lt;span class="c1"&gt;// 1. Kinde is the authority on the organization.&lt;/span&gt;
    &lt;span class="k"&gt;await&lt;/span&gt; &lt;span class="nf"&gt;setOrgSuspended&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="nx"&gt;orgCode&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="kc"&gt;true&lt;/span&gt;&lt;span class="p"&gt;);&lt;/span&gt;
    &lt;span class="c1"&gt;// 2. The enforcement copy, which the seam reads on every call. This is the&lt;/span&gt;
    &lt;span class="c1"&gt;//    one that stops a running swarm.&lt;/span&gt;
    &lt;span class="k"&gt;await&lt;/span&gt; &lt;span class="nx"&gt;ctx&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nf"&gt;runMutation&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="nx"&gt;internal&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nx"&gt;tenants&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nx"&gt;setSuspended&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="p"&gt;{&lt;/span&gt;
      &lt;span class="nx"&gt;orgCode&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="na"&gt;isSuspended&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="kc"&gt;true&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt;
    &lt;span class="p"&gt;});&lt;/span&gt;
    &lt;span class="c1"&gt;// 3. A suspended tenant cannot close its own runs, so close them here.&lt;/span&gt;
    &lt;span class="k"&gt;await&lt;/span&gt; &lt;span class="nx"&gt;ctx&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nf"&gt;runMutation&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="nx"&gt;internal&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nx"&gt;runs&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nx"&gt;killInFlight&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="p"&gt;{&lt;/span&gt; &lt;span class="nx"&gt;orgCode&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="nx"&gt;orgCode&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="na"&gt;suspended&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="kc"&gt;true&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;The Management API call, with the guard that matters as much:&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight typescript"&gt;&lt;code&gt;&lt;span class="c1"&gt;// convex/lib/kindeManagement.ts&lt;/span&gt;
&lt;span class="k"&gt;export&lt;/span&gt; &lt;span class="k"&gt;async&lt;/span&gt; &lt;span class="kd"&gt;function&lt;/span&gt; &lt;span class="nf"&gt;setOrgSuspended&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="nx"&gt;orgCode&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="kr"&gt;string&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="nx"&gt;isSuspended&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="nx"&gt;boolean&lt;/span&gt;&lt;span class="p"&gt;)&lt;/span&gt; &lt;span class="p"&gt;{&lt;/span&gt;
  &lt;span class="nf"&gt;assertManageable&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="nx"&gt;orgCode&lt;/span&gt;&lt;span class="p"&gt;);&lt;/span&gt;             &lt;span class="c1"&gt;// one of this deployment's tenants only&lt;/span&gt;
  &lt;span class="kd"&gt;const&lt;/span&gt; &lt;span class="nx"&gt;token&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="k"&gt;await&lt;/span&gt; &lt;span class="nf"&gt;managementToken&lt;/span&gt;&lt;span class="p"&gt;();&lt;/span&gt;  &lt;span class="c1"&gt;// audience: {issuer}/api&lt;/span&gt;

  &lt;span class="kd"&gt;const&lt;/span&gt; &lt;span class="nx"&gt;response&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="k"&gt;await&lt;/span&gt; &lt;span class="nf"&gt;fetch&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="nf"&gt;issuer&lt;/span&gt;&lt;span class="p"&gt;()}&lt;/span&gt;&lt;span class="s2"&gt;/api/v1/organization/&lt;/span&gt;&lt;span class="p"&gt;${&lt;/span&gt;&lt;span class="nf"&gt;encodeURIComponent&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="nx"&gt;orgCode&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="na"&gt;method&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="dl"&gt;"&lt;/span&gt;&lt;span class="s2"&gt;PATCH&lt;/span&gt;&lt;span class="dl"&gt;"&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt;
      &lt;span class="na"&gt;headers&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="p"&gt;{&lt;/span&gt; &lt;span class="na"&gt;authorization&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="s2"&gt;`Bearer &lt;/span&gt;&lt;span class="p"&gt;${&lt;/span&gt;&lt;span class="nx"&gt;token&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="dl"&gt;"&lt;/span&gt;&lt;span class="s2"&gt;content-type&lt;/span&gt;&lt;span class="dl"&gt;"&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="dl"&gt;"&lt;/span&gt;&lt;span class="s2"&gt;application/json&lt;/span&gt;&lt;span class="dl"&gt;"&lt;/span&gt; &lt;span class="p"&gt;},&lt;/span&gt;
      &lt;span class="na"&gt;body&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;is_suspended&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="nx"&gt;isSuspended&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;if &lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="o"&gt;!&lt;/span&gt;&lt;span class="nx"&gt;response&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nx"&gt;ok&lt;/span&gt;&lt;span class="p"&gt;)&lt;/span&gt; &lt;span class="k"&gt;throw&lt;/span&gt; &lt;span class="k"&gt;new&lt;/span&gt; &lt;span class="nc"&gt;Error&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="s2"&gt;`could not update &lt;/span&gt;&lt;span class="p"&gt;${&lt;/span&gt;&lt;span class="nx"&gt;orgCode&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;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;&lt;code&gt;assertManageable&lt;/code&gt; refuses any organization outside the three the deployment knows. A Kinde account holds other organizations, and a wrong code should never suspend one of them.&lt;/p&gt;

&lt;p&gt;Tested with two real swarms running and one tenant suspended twelve seconds in:&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="err"&gt;tenant&lt;/span&gt;&lt;span class="w"&gt; &lt;/span&gt;&lt;span class="err"&gt;A&lt;/span&gt;&lt;span class="w"&gt; &lt;/span&gt;&lt;span class="err"&gt;audit:&lt;/span&gt;&lt;span class="w"&gt; &lt;/span&gt;&lt;span class="p"&gt;{&lt;/span&gt;&lt;span class="nl"&gt;"ok"&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt;&lt;span class="w"&gt; &lt;/span&gt;&lt;span class="mi"&gt;2&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt;&lt;span class="w"&gt; &lt;/span&gt;&lt;span class="nl"&gt;"organization_suspended"&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt;&lt;span class="w"&gt; &lt;/span&gt;&lt;span class="mi"&gt;17&lt;/span&gt;&lt;span class="p"&gt;}&lt;/span&gt;&lt;span class="w"&gt;
&lt;/span&gt;&lt;span class="err"&gt;tenant&lt;/span&gt;&lt;span class="w"&gt; &lt;/span&gt;&lt;span class="err"&gt;B&lt;/span&gt;&lt;span class="w"&gt; &lt;/span&gt;&lt;span class="err"&gt;audit:&lt;/span&gt;&lt;span class="w"&gt; &lt;/span&gt;&lt;span class="p"&gt;{&lt;/span&gt;&lt;span class="nl"&gt;"ok"&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt;&lt;span class="w"&gt; &lt;/span&gt;&lt;span class="mi"&gt;39&lt;/span&gt;&lt;span class="p"&gt;}&lt;/span&gt;&lt;span class="w"&gt;
&lt;/span&gt;&lt;span class="err"&gt;tenant&lt;/span&gt;&lt;span class="w"&gt; &lt;/span&gt;&lt;span class="err"&gt;B&lt;/span&gt;&lt;span class="w"&gt; &lt;/span&gt;&lt;span class="err"&gt;run&lt;/span&gt;&lt;span class="w"&gt; &lt;/span&gt;&lt;span class="err"&gt;status:&lt;/span&gt;&lt;span class="w"&gt; &lt;/span&gt;&lt;span class="err"&gt;completed&lt;/span&gt;&lt;span class="w"&gt;
&lt;/span&gt;&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;Tenant A did real work, then stopped where it stood. Tenant B finished normally and was never refused.&lt;/p&gt;

&lt;h3&gt;
  
  
  One side effect to design for
&lt;/h3&gt;

&lt;p&gt;A suspended tenant cannot write anything, including its own log lines, because the log endpoint sits behind the same seam. The live timeline stops dead. On screen that reads as a hang, so the console says why.&lt;/p&gt;

&lt;h3&gt;
  
  
  Checkpoint
&lt;/h3&gt;

&lt;p&gt;Suspend a tenant while one of its agents holds a valid token, then reuse that same token. You should get &lt;code&gt;403 organization_suspended&lt;/code&gt;, not a success and not a network error. Then request a fresh token for that tenant. Kinde will issue one, and the server should refuse that too.&lt;/p&gt;

&lt;h2&gt;
  
  
  How to Watch It Happen
&lt;/h2&gt;

&lt;p&gt;The same agent makes the same call for the same record, twice. Only the server's mode differs.&lt;/p&gt;

&lt;p&gt;&lt;strong&gt;Shared mode.&lt;/strong&gt; The call returns &lt;code&gt;200&lt;/code&gt;, and the response carries tenant B's confidential content. The audit row records an allow that crossed a tenant boundary:&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="s"&gt;allow / cross_org_allowed&lt;/span&gt;
&lt;span class="na"&gt;actor&lt;/span&gt;&lt;span class="pi"&gt;:&lt;/span&gt;  &lt;span class="s"&gt;org_2606b8199462b&lt;/span&gt;
&lt;span class="na"&gt;target&lt;/span&gt;&lt;span class="pi"&gt;:&lt;/span&gt; &lt;span class="s"&gt;org_364dd8200a3d3&lt;/span&gt;
&lt;span class="na"&gt;action&lt;/span&gt;&lt;span class="pi"&gt;:&lt;/span&gt; &lt;span class="s"&gt;resource.read&lt;/span&gt;
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;&lt;strong&gt;Per-org mode.&lt;/strong&gt; The identical call returns &lt;code&gt;403&lt;/code&gt;, and no content comes back:&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;"ok"&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt;&lt;span class="w"&gt; &lt;/span&gt;&lt;span class="kc"&gt;false&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt;&lt;span class="w"&gt;
  &lt;/span&gt;&lt;span class="nl"&gt;"reason"&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt;&lt;span class="w"&gt; &lt;/span&gt;&lt;span class="s2"&gt;"cross_org"&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt;&lt;span class="w"&gt;
  &lt;/span&gt;&lt;span class="nl"&gt;"correlationId"&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt;&lt;span class="w"&gt; &lt;/span&gt;&lt;span class="s2"&gt;"2f0f21d6-a9a3-43e5-8153-884992dbd166"&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt;&lt;span class="w"&gt;
  &lt;/span&gt;&lt;span class="nl"&gt;"isolationMode"&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt;&lt;span class="w"&gt; &lt;/span&gt;&lt;span class="s2"&gt;"per-org"&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;Side by side:&lt;/p&gt;

&lt;div class="table-wrapper-paragraph"&gt;&lt;table&gt;
&lt;thead&gt;
&lt;tr&gt;
&lt;th&gt;Metric&lt;/th&gt;
&lt;th&gt;&lt;code&gt;shared&lt;/code&gt;&lt;/th&gt;
&lt;th&gt;&lt;code&gt;per-org&lt;/code&gt;&lt;/th&gt;
&lt;/tr&gt;
&lt;/thead&gt;
&lt;tbody&gt;
&lt;tr&gt;
&lt;td&gt;workers&lt;/td&gt;
&lt;td&gt;3&lt;/td&gt;
&lt;td&gt;3&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;tool calls&lt;/td&gt;
&lt;td&gt;6&lt;/td&gt;
&lt;td&gt;11&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;cross-org attempts&lt;/td&gt;
&lt;td&gt;2&lt;/td&gt;
&lt;td&gt;2&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;blocked&lt;/td&gt;
&lt;td&gt;0&lt;/td&gt;
&lt;td&gt;2&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;escapes&lt;/td&gt;
&lt;td&gt;&lt;strong&gt;2&lt;/strong&gt;&lt;/td&gt;
&lt;td&gt;&lt;strong&gt;0&lt;/strong&gt;&lt;/td&gt;
&lt;/tr&gt;
&lt;/tbody&gt;
&lt;/table&gt;&lt;/div&gt;

&lt;p&gt;Return the correlation id with the refusal. The agent sees it, the audit row carries it, and the console shows it. One identifier ties a refusal to the run that caused it.&lt;/p&gt;

&lt;p&gt;Least privilege has to hold inside a tenant, not only between tenants. Three calls, one correlation id:&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight plaintext"&gt;&lt;code&gt;deny  / insufficient_scope    read agent tried to write to its own tenant
allow / ok                    write agent wrote to its own tenant
deny  / cross_org             write agent tried to write to another tenant
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;Here is the same agent, under &lt;code&gt;per-org&lt;/code&gt;, reporting the refusal itself:&lt;/p&gt;

&lt;blockquote&gt;
&lt;p&gt;The reads against Tenant A and Tenant C were refused with reason "cross_org". Per-org isolation prevents me from accessing other organizations' records, so I did not retry.&lt;/p&gt;
&lt;/blockquote&gt;

&lt;p&gt;The agent did not change, and the prompt did not change. The identity it carried changed, and the server checked that identity on every call.&lt;/p&gt;

&lt;h2&gt;
  
  
  What Least Privilege Actually Costs
&lt;/h2&gt;

&lt;p&gt;The write agent holds &lt;code&gt;resource:write&lt;/code&gt; and nothing else. That is correct, and it broke the swarm the first time it ran.&lt;/p&gt;

&lt;p&gt;The agent made eight tool calls, and the server refused all eight. It tried to list records, and the server refused, because listing needs &lt;code&gt;resource:read&lt;/code&gt;. It tried to read a record, and the server refused for the same reason.&lt;/p&gt;

&lt;p&gt;The write agent had been given read tools it could never use.&lt;/p&gt;

&lt;p&gt;The fix is not a wider scope. The fix is to change how work reaches that agent. The orchestrator looks up the record with a read identity, then passes the record id in the agent's task:&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;# swarm/orchestrator.py
&lt;/span&gt;&lt;span class="n"&gt;listing&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="n"&gt;control&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nf"&gt;call&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="sh"&gt;"&lt;/span&gt;&lt;span class="s"&gt;/tools/resource.list&lt;/span&gt;&lt;span class="sh"&gt;"&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="n"&gt;opener&lt;/span&gt;&lt;span class="p"&gt;)&lt;/span&gt;   &lt;span class="c1"&gt;# reader identity
&lt;/span&gt;&lt;span class="n"&gt;chosen&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="nf"&gt;next&lt;/span&gt;&lt;span class="p"&gt;((&lt;/span&gt;&lt;span class="n"&gt;r&lt;/span&gt; &lt;span class="k"&gt;for&lt;/span&gt; &lt;span class="n"&gt;r&lt;/span&gt; &lt;span class="ow"&gt;in&lt;/span&gt; &lt;span class="n"&gt;rows&lt;/span&gt; &lt;span class="k"&gt;if&lt;/span&gt; &lt;span class="n"&gt;r&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nf"&gt;get&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="sh"&gt;"&lt;/span&gt;&lt;span class="s"&gt;key&lt;/span&gt;&lt;span class="sh"&gt;"&lt;/span&gt;&lt;span class="p"&gt;)&lt;/span&gt; &lt;span class="o"&gt;==&lt;/span&gt; &lt;span class="sh"&gt;"&lt;/span&gt;&lt;span class="s"&gt;consolidated-summary&lt;/span&gt;&lt;span class="sh"&gt;"&lt;/span&gt;&lt;span class="p"&gt;),&lt;/span&gt; &lt;span class="bp"&gt;None&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;chosen&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt;
    &lt;span class="n"&gt;write_target&lt;/span&gt; &lt;span class="o"&gt;=&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="se"&gt;\n&lt;/span&gt;&lt;span class="s"&gt;Write to record id &lt;/span&gt;&lt;span class="si"&gt;{&lt;/span&gt;&lt;span class="n"&gt;chosen&lt;/span&gt;&lt;span class="p"&gt;[&lt;/span&gt;&lt;span class="sh"&gt;'&lt;/span&gt;&lt;span class="s"&gt;id&lt;/span&gt;&lt;span class="sh"&gt;'&lt;/span&gt;&lt;span class="p"&gt;]&lt;/span&gt;&lt;span class="si"&gt;}&lt;/span&gt;&lt;span class="s"&gt; (key &lt;/span&gt;&lt;span class="si"&gt;{&lt;/span&gt;&lt;span class="n"&gt;chosen&lt;/span&gt;&lt;span class="p"&gt;[&lt;/span&gt;&lt;span class="sh"&gt;'&lt;/span&gt;&lt;span class="s"&gt;key&lt;/span&gt;&lt;span class="sh"&gt;'&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="sh"&gt;"&lt;/span&gt;
                    &lt;span class="sa"&gt;f&lt;/span&gt;&lt;span class="sh"&gt;"&lt;/span&gt;&lt;span class="s"&gt;which belongs to &lt;/span&gt;&lt;span class="si"&gt;{&lt;/span&gt;&lt;span class="n"&gt;tenant_name&lt;/span&gt;&lt;span class="si"&gt;}&lt;/span&gt;&lt;span class="s"&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;else&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt;
    &lt;span class="c1"&gt;# Saying nothing here left the agent to invent a record id, which the
&lt;/span&gt;    &lt;span class="c1"&gt;# backend then refused as malformed.
&lt;/span&gt;    &lt;span class="n"&gt;write_target&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="sh"&gt;"&lt;/span&gt;&lt;span class="se"&gt;\n&lt;/span&gt;&lt;span class="s"&gt;No record is available to write to: looking one up was &lt;/span&gt;&lt;span class="sh"&gt;"&lt;/span&gt;
                    &lt;span class="sa"&gt;f&lt;/span&gt;&lt;span class="sh"&gt;"&lt;/span&gt;&lt;span class="s"&gt;refused (&lt;/span&gt;&lt;span class="si"&gt;{&lt;/span&gt;&lt;span class="n"&gt;listing&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="n"&gt;reason&lt;/span&gt;&lt;span class="si"&gt;}&lt;/span&gt;&lt;span class="s"&gt;). Report that you could not &lt;/span&gt;&lt;span class="sh"&gt;"&lt;/span&gt;
                    &lt;span class="sh"&gt;"&lt;/span&gt;&lt;span class="s"&gt;write, and give the reason. Do not invent a record id.&lt;/span&gt;&lt;span class="sh"&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 second branch came from a later failure. When the lookup was refused, the write agent invented a record id and the server rejected it as malformed. An empty instruction left a gap, and the model filled it.&lt;/p&gt;

&lt;p&gt;There is a sequencing cost too. The first version ran all three agents in parallel, and the write agent had nothing to write:&lt;/p&gt;

&lt;blockquote&gt;
&lt;p&gt;I cannot complete this task as specified. My only available tool is &lt;code&gt;write_resource&lt;/code&gt;, there is no read tool, so I have no way to retrieve the readers' findings.&lt;/p&gt;
&lt;/blockquote&gt;

&lt;p&gt;That is a correct complaint. Read agents now run first, and the write agent receives their findings. Least privilege forced a real change to the swarm topology, not just to a configuration file.&lt;/p&gt;

&lt;h2&gt;
  
  
  Three More Things That Broke
&lt;/h2&gt;

&lt;p&gt;&lt;strong&gt;The swarm poisoned its own data.&lt;/strong&gt; One run wrote its summary into the invoice record that the read agents read. The next run read that text and obeyed it, reporting that consolidation was not authorized. The demo had become a feedback loop. Each tenant now has a separate summary record, and that record is the only write target.&lt;/p&gt;

&lt;p&gt;&lt;strong&gt;Parallel agents hit limits.&lt;/strong&gt; Six agents against an account that allows three requests in flight produced rate limit errors that damaged two runs. Model calls now pass through a semaphore, and rate limits retry with backoff. A rate limit is a "not now", not a "no". Refusals with a 4xx status never retry, because a refusal is an answer.&lt;/p&gt;

&lt;p&gt;&lt;strong&gt;Model settings are not portable.&lt;/strong&gt; The first run failed on every agent with &lt;code&gt;invalid temperature: only 1 is allowed for this model&lt;/code&gt;. The retry logic then blamed a different parameter and retried into a second failure. The retry now checks what the API objected to before it changes anything.&lt;/p&gt;

&lt;p&gt;Running the thing found all three. Reading the code found none of them.&lt;/p&gt;

&lt;h2&gt;
  
  
  How to Bring This Up to Production Standard
&lt;/h2&gt;

&lt;ul&gt;
&lt;li&gt;&lt;p&gt;&lt;strong&gt;Short-lived tokens.&lt;/strong&gt; Set the token lifetime for each M2M application in the Kinde dashboard. The demo received 24 hour tokens by default.&lt;/p&gt;&lt;/li&gt;
&lt;li&gt;&lt;p&gt;&lt;strong&gt;One scope for each role.&lt;/strong&gt; A read agent gets read. A write agent gets one narrow write scope.&lt;/p&gt;&lt;/li&gt;
&lt;li&gt;&lt;p&gt;&lt;strong&gt;Server-side checks only.&lt;/strong&gt; Compare &lt;code&gt;org_code&lt;/code&gt; against the record owner on the server. An agent never states its own authority.&lt;/p&gt;&lt;/li&gt;
&lt;li&gt;&lt;p&gt;&lt;strong&gt;Check against JWKS, and cache the key set.&lt;/strong&gt; Create one fetcher for each issuer. The library refetches only when it meets an unknown key id.&lt;/p&gt;&lt;/li&gt;
&lt;li&gt;&lt;p&gt;&lt;strong&gt;Pin the algorithm.&lt;/strong&gt; Accept RS256 and reject the rest.&lt;/p&gt;&lt;/li&gt;
&lt;li&gt;&lt;p&gt;&lt;strong&gt;Fail closed.&lt;/strong&gt; An unknown tenant, a missing claim, or a failed lookup all refuse.&lt;/p&gt;&lt;/li&gt;
&lt;li&gt;&lt;p&gt;&lt;strong&gt;Audit every decision.&lt;/strong&gt; Write the row before you answer the caller.&lt;/p&gt;&lt;/li&gt;
&lt;li&gt;&lt;p&gt;&lt;strong&gt;Carry a correlation id end to end.&lt;/strong&gt; Put it in the response, the audit row, and the timeline.&lt;/p&gt;&lt;/li&gt;
&lt;li&gt;&lt;p&gt;&lt;strong&gt;Show four numbers.&lt;/strong&gt; Agents, cross-tenant attempts, blocked, escapes. The last number should be zero.&lt;/p&gt;&lt;/li&gt;
&lt;/ul&gt;

&lt;p&gt;On token lifetime: because suspension never invalidated tokens, a shorter lifetime does not make the kill switch work. The server check does that, and it takes effect at once. A shorter lifetime reduces the damage from a token that leaks some other way. Both are worth having, for different reasons.&lt;/p&gt;

&lt;h2&gt;
  
  
  How This Maps to Your Own App
&lt;/h2&gt;

&lt;div class="table-wrapper-paragraph"&gt;&lt;table&gt;
&lt;thead&gt;
&lt;tr&gt;
&lt;th&gt;In this demo&lt;/th&gt;
&lt;th&gt;In your application&lt;/th&gt;
&lt;/tr&gt;
&lt;/thead&gt;
&lt;tbody&gt;
&lt;tr&gt;
&lt;td&gt;Tenant&lt;/td&gt;
&lt;td&gt;Your customer, workspace, or project&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;Kinde organization&lt;/td&gt;
&lt;td&gt;One for each tenant&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;Agent role&lt;/td&gt;
&lt;td&gt;One M2M application inside that organization&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;Authority&lt;/td&gt;
&lt;td&gt;The token, and nothing else&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;The seam&lt;/td&gt;
&lt;td&gt;The one function every agent call passes through&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;Break-glass control&lt;/td&gt;
&lt;td&gt;Organization suspension, plus a server-side check&lt;/td&gt;
&lt;/tr&gt;
&lt;/tbody&gt;
&lt;/table&gt;&lt;/div&gt;

&lt;p&gt;Three questions tell you whether your own build holds:&lt;/p&gt;

&lt;ol&gt;
&lt;li&gt;&lt;p&gt;Can an agent name its own tenant, and be believed? If yes, the boundary is not real.&lt;/p&gt;&lt;/li&gt;
&lt;li&gt;&lt;p&gt;Is there exactly one place that decides? If a second place exists, there are two rules, and they will drift.&lt;/p&gt;&lt;/li&gt;
&lt;li&gt;&lt;p&gt;If you break the check on purpose, do your tests fail? If not, the tests measure nothing.&lt;/p&gt;&lt;/li&gt;
&lt;/ol&gt;

&lt;h2&gt;
  
  
  Limits of This Build
&lt;/h2&gt;

&lt;ul&gt;
&lt;li&gt;&lt;p&gt;&lt;strong&gt;The console has no operator login.&lt;/strong&gt; The mode switch and the kill switch are public functions. No agent can reach them, because agents hold Kinde tokens and call only the tool endpoints. Anyone with the deployment URL can. Run it locally, and put a login in front for anything real.&lt;/p&gt;&lt;/li&gt;
&lt;li&gt;&lt;p&gt;&lt;strong&gt;Three agents, not three hundred.&lt;/strong&gt; Moonshot's own swarm runs up to 300 sub-agents, and this build runs three. The pattern does not change with scale, because the check happens on one call at a time. The operational load does change: 4,000 tool calls produce 4,000 audit rows, and that needs somewhere to go.&lt;/p&gt;&lt;/li&gt;
&lt;li&gt;&lt;p&gt;&lt;strong&gt;Model behaviour varies.&lt;/strong&gt; In one shared-mode run the agents did not reach across at all. The reach is not scripted, so the end-to-end test asserts the enforcement outcomes exactly and reports the agent behaviour as information.&lt;/p&gt;&lt;/li&gt;
&lt;li&gt;&lt;p&gt;&lt;strong&gt;The demo starts a local process.&lt;/strong&gt; The console runs the Python swarm on the machine that serves the web app, so it needs a host that runs Python.&lt;/p&gt;&lt;/li&gt;
&lt;/ul&gt;

&lt;h2&gt;
  
  
  Wrapping Up
&lt;/h2&gt;

&lt;p&gt;The identity an agent carries is the only thing your API can check. Give the whole swarm one credential, and one agent that goes wrong reaches every customer you have. Give each agent an identity scoped to one tenant, check it on every call, and the same failure stops at one.&lt;/p&gt;

&lt;p&gt;The parts that carry the weight are small: one organization for each tenant, one M2M application for each agent role, one function that decides, and one audit row for every decision. The rest is wiring.&lt;/p&gt;

&lt;p&gt;Measure the things you plan to depend on. Organization suspension looked like a kill switch and was not one, and five minutes of checking is what turned that into a design decision instead of an outage.&lt;/p&gt;

&lt;h2&gt;
  
  
  Resources
&lt;/h2&gt;

&lt;p&gt;&lt;strong&gt;Source code&lt;/strong&gt;&lt;/p&gt;

&lt;p&gt;The complete demo is on GitHub at &lt;a href="https://github.com/sholajegede/swarm-isolation-demo" rel="noopener noreferrer"&gt;sholajegede/swarm-isolation-demo&lt;/a&gt;. Run &lt;code&gt;pnpm e2e --no-swarm&lt;/code&gt; first. It walks the whole story in one pass and costs no model calls.&lt;/p&gt;

&lt;p&gt;&lt;strong&gt;Kinde documentation&lt;/strong&gt;&lt;/p&gt;

&lt;ul&gt;
&lt;li&gt;&lt;p&gt;&lt;a href="https://docs.kinde.com/machine-to-machine-applications/organization-scoped-m2m-apps/using-m2m-apps-for-ai-applications/" rel="noopener noreferrer"&gt;Using M2M apps for AI applications&lt;/a&gt;: the pattern this tutorial builds, in Kinde's own words&lt;/p&gt;&lt;/li&gt;
&lt;li&gt;&lt;p&gt;&lt;a href="https://docs.kinde.com/machine-to-machine-applications/organization-scoped-m2m-apps/enforce-org-m2m-access-in-your-api/" rel="noopener noreferrer"&gt;Enforce org access in your API using M2M tokens&lt;/a&gt;: the check at the heart of this build&lt;/p&gt;&lt;/li&gt;
&lt;li&gt;&lt;p&gt;&lt;a href="https://docs.kinde.com/machine-to-machine-applications/organization-scoped-m2m-apps/m2m-applications-for-organizations/" rel="noopener noreferrer"&gt;M2M apps scoped to organizations&lt;/a&gt;: how to create one application for each tenant&lt;/p&gt;&lt;/li&gt;
&lt;li&gt;&lt;p&gt;&lt;a href="https://docs.kinde.com/machine-to-machine-applications/about-m2m/token-structure-and-claims-for-m2m-applications/" rel="noopener noreferrer"&gt;Token structure and claims for M2M applications&lt;/a&gt;: where &lt;code&gt;org_code&lt;/code&gt; comes from&lt;/p&gt;&lt;/li&gt;
&lt;li&gt;&lt;p&gt;&lt;a href="https://docs.kinde.com/build/organizations/organization-m2m-applications/" rel="noopener noreferrer"&gt;Give API access to an organization using M2M&lt;/a&gt;&lt;/p&gt;&lt;/li&gt;
&lt;li&gt;&lt;p&gt;&lt;a href="https://docs.kinde.com/developer-tools/kinde-api/connect-to-kinde-api/" rel="noopener noreferrer"&gt;Set up Kinde Management API access&lt;/a&gt;: needed for the kill switch&lt;/p&gt;&lt;/li&gt;
&lt;/ul&gt;

&lt;p&gt;&lt;strong&gt;Kimi K3 and Agent Swarm&lt;/strong&gt;&lt;/p&gt;

&lt;ul&gt;
&lt;li&gt;&lt;p&gt;&lt;a href="https://www.kimi.com/help/agent/agent-swarm" rel="noopener noreferrer"&gt;Agent Swarm, Kimi Help Center&lt;/a&gt;: the source for the 300 sub-agent and 4,000 tool call figures&lt;/p&gt;&lt;/li&gt;
&lt;li&gt;&lt;p&gt;&lt;a href="https://github.com/MoonshotAI/kimi-help-center/blob/master/en-US/agent/swarm.md" rel="noopener noreferrer"&gt;Agent Swarm, Moonshot's help centre repository&lt;/a&gt;: the same document, in public source&lt;/p&gt;&lt;/li&gt;
&lt;li&gt;&lt;p&gt;&lt;a href="https://www.kimi.com/blog/agent-swarm" rel="noopener noreferrer"&gt;Kimi Agent Swarm announcement&lt;/a&gt;: Moonshot's write-up of the architecture&lt;/p&gt;&lt;/li&gt;
&lt;li&gt;&lt;p&gt;&lt;a href="https://simonwillison.net/2026/Jul/16/kimi-k3/" rel="noopener noreferrer"&gt;Kimi K3, and what we can still learn from the pelican benchmark&lt;/a&gt;: Simon Willison on the K3 release&lt;/p&gt;&lt;/li&gt;
&lt;/ul&gt;

&lt;p&gt;&lt;strong&gt;Further reading&lt;/strong&gt;&lt;/p&gt;

&lt;ul&gt;
&lt;li&gt;&lt;p&gt;&lt;a href="https://datatracker.ietf.org/doc/html/rfc6749#section-4.4" rel="noopener noreferrer"&gt;OAuth 2.0 client credentials grant&lt;/a&gt;: the flow every agent uses to get its token&lt;/p&gt;&lt;/li&gt;
&lt;li&gt;&lt;p&gt;&lt;a href="https://datatracker.ietf.org/doc/html/rfc7517" rel="noopener noreferrer"&gt;JSON Web Key Sets&lt;/a&gt;: how your API fetches the public keys it checks signatures against&lt;/p&gt;&lt;/li&gt;
&lt;/ul&gt;

</description>
      <category>kinde</category>
      <category>kimi</category>
      <category>ai</category>
      <category>agents</category>
    </item>
    <item>
      <title>How to Build a Tamper-Evident Audit Trail for AI Agent Actions</title>
      <dc:creator>Shola Jegede</dc:creator>
      <pubDate>Fri, 07 Aug 2026 13:51:55 +0000</pubDate>
      <link>https://dev.to/sholajegede/how-to-build-an-audit-trail-for-ai-agents-that-an-auditor-would-actually-accept-5836</link>
      <guid>https://dev.to/sholajegede/how-to-build-an-audit-trail-for-ai-agents-that-an-auditor-would-actually-accept-5836</guid>
      <description>&lt;p&gt;A law firm runs a set of AI agents over its case files. One agent takes in new documents. A second agent reads each file and adds notes. A third agent redacts, exports, and disposes of files when a matter closes.&lt;/p&gt;

&lt;p&gt;One morning the review agent deletes a file. The file is a deposition transcript in an active case: &lt;em&gt;Halvorsen v. Meridian Logistics&lt;/em&gt;. The review agent is only allowed to read and annotate. It is not allowed to delete anything.&lt;/p&gt;

&lt;p&gt;The delete still happens.&lt;/p&gt;

&lt;p&gt;Weeks later an auditor asks a simple question: &lt;em&gt;prove that the deletion of this file was authorized.&lt;/em&gt; The firm opens its activity log. The log has one line for the event:&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight plaintext"&gt;&lt;code&gt;records:delete  agent=review  file=halvorsen-deposition  2026-08-06T09:51:59Z
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;The line says what happened. It does not say whether the agent was allowed to do it. So the firm cannot answer the auditor. The log records the action, but it does not record the authority behind the action. An agent audit trail must record more than events.&lt;/p&gt;

&lt;h2&gt;
  
  
  Why a normal log fails
&lt;/h2&gt;

&lt;p&gt;Most application logs answer one question: what happened? They record an actor, an action, a target, and a time. For human users that is often enough, because a human signs in once and carries a session. The permission check happened at the door.&lt;/p&gt;

&lt;p&gt;AI agents break this model. An agent acts many times inside one run. It calls tools, it reaches other agents, and it acts on data on behalf of a person who is not in the loop for each step. The permission that matters is not "did someone sign in." It is "was this specific action, by this specific agent, allowed at the moment it ran."&lt;/p&gt;

&lt;p&gt;A normal log cannot answer that, for three reasons:&lt;/p&gt;

&lt;ul&gt;
&lt;li&gt;It records the action, not the decision. There is no field that says "allowed" or "denied," and no field for the rule that was checked.&lt;/li&gt;
&lt;li&gt;It records an actor name, not a verified identity. &lt;code&gt;agent=review&lt;/code&gt; is a label the application wrote. It is not proof that the caller was the review agent and held the right permissions.&lt;/li&gt;
&lt;li&gt;It cannot tell an allowed action from a denied one. If the review agent's illegal delete and a lawful delete by the disposition agent both produce the same line, the log has erased the one difference that matters.&lt;/li&gt;
&lt;/ul&gt;

&lt;p&gt;Look again at the two log lines below. One delete was allowed. One was not. The log cannot tell you which is which.&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight plaintext"&gt;&lt;code&gt;records:delete  agent=disposition  file=cormorant-memo         2026-08-06T09:51:59Z
records:delete  agent=review       file=halvorsen-deposition   2026-08-06T09:51:59Z
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;The disposition agent is allowed to delete. The review agent is not. But the two lines have the same shape and the same fields. Neither line carries the permission that was in force. To an auditor, both deletions are equally unprovable.&lt;/p&gt;

&lt;p&gt;The diagram below shows the blind log at the center of the problem. Two deletes go in. The log flattens them into the same shape, and the authority is gone.&lt;/p&gt;

&lt;p&gt;&lt;a href="https://media2.dev.to/dynamic/image/width=800%2Cheight=%2Cfit=scale-down%2Cgravity=auto%2Cformat=auto/https%3A%2F%2Fdev-to-uploads.s3.us-east-2.amazonaws.com%2Fuploads%2Farticles%2Fyxa31gfje9wtwm46irdp.png" class="article-body-image-wrapper"&gt;&lt;img src="https://media2.dev.to/dynamic/image/width=800%2Cheight=%2Cfit=scale-down%2Cgravity=auto%2Cformat=auto/https%3A%2F%2Fdev-to-uploads.s3.us-east-2.amazonaws.com%2Fuploads%2Farticles%2Fyxa31gfje9wtwm46irdp.png" alt="Two agents delete a file: the disposition agent, which is allowed, and the review&lt;br&gt;
  agent, which is not. Both deletes write the same-shaped line to the activity log.&lt;br&gt;
  When the auditor asks whether a delete was allowed, the record holds no authority,&lt;br&gt;
  so the firm cannot prove it." width="800" height="1302"&gt;&lt;/a&gt;&lt;/p&gt;
&lt;h2&gt;
  
  
  What the auditor actually wants
&lt;/h2&gt;

&lt;p&gt;An auditor wants a record that answers four questions for every sensitive action:&lt;/p&gt;

&lt;ul&gt;
&lt;li&gt;
&lt;strong&gt;Who&lt;/strong&gt; took the action. This is a verified identity, not a label.&lt;/li&gt;
&lt;li&gt;
&lt;strong&gt;On whose authority.&lt;/strong&gt; This is the person or agent the action was performed for.&lt;/li&gt;
&lt;li&gt;
&lt;strong&gt;Under what permission.&lt;/strong&gt; These are the exact rights that were in force, and the decision that was made.&lt;/li&gt;
&lt;li&gt;
&lt;strong&gt;Can you prove the record was not changed.&lt;/strong&gt; This shows the row is the original, not edited after the fact.&lt;/li&gt;
&lt;/ul&gt;

&lt;p&gt;A normal log answers none of these with confidence. It gives a name, an action, and a time. The rest is missing. So the honest answer to the auditor is "we cannot prove it," which is the answer no regulated business can give.&lt;/p&gt;

&lt;p&gt;&lt;a href="https://media2.dev.to/dynamic/image/width=800%2Cheight=%2Cfit=scale-down%2Cgravity=auto%2Cformat=auto/https%3A%2F%2Fdev-to-uploads.s3.us-east-2.amazonaws.com%2Fuploads%2Farticles%2F38wiabqrkf4zkkphu495.png" class="article-body-image-wrapper"&gt;&lt;img src="https://media2.dev.to/dynamic/image/width=800%2Cheight=%2Cfit=scale-down%2Cgravity=auto%2Cformat=auto/https%3A%2F%2Fdev-to-uploads.s3.us-east-2.amazonaws.com%2Fuploads%2Farticles%2F38wiabqrkf4zkkphu495.png" alt="An audit checklist on a single sheet of paper, flat-lay on a white desk. Four  items, each with a small green check; one item is a monospace id and hash fragment. Near-black ink on white, generous whitespace, no other color." width="800" height="450"&gt;&lt;/a&gt;&lt;/p&gt;
&lt;h2&gt;
  
  
  Three fixes that do not work
&lt;/h2&gt;

&lt;p&gt;Teams reach for the same three fixes when they hit this problem. Each one seems reasonable. Each one fails.&lt;/p&gt;

&lt;p&gt;&lt;strong&gt;Fix 1: log more fields.&lt;/strong&gt; Add the agent's role, the file's status, and a note to each line. This adds detail, but it does not add proof. The application still writes the fields itself, so the log still says &lt;code&gt;agent=review&lt;/code&gt; without checking that the caller really was the review agent or that the review agent held delete rights. A richer log of unverified claims is still unverified.&lt;/p&gt;

&lt;p&gt;&lt;strong&gt;Fix 2: check the permission once, at the start of the run.&lt;/strong&gt; Verify the human's role when the run begins, then let the agents act. This fails because the agents do different work with different rights. The review agent should never delete, even in a run a partner started. One check at the door cannot bind each action inside the run.&lt;/p&gt;

&lt;p&gt;&lt;strong&gt;Fix 3: trust the caller's claim.&lt;/strong&gt; Let each agent tell the system what it is allowed to do, and record that. This is the worst fix, because it lets any agent widen its own authority by claiming more. The record then documents a lie. An auditor trusts a record that the actor could not forge, not one the actor wrote about itself.&lt;/p&gt;

&lt;p&gt;The pattern across all three is the same. They try to describe the action better. The real fix is different: record the &lt;strong&gt;decision&lt;/strong&gt;, made by something the agent cannot control, and make the record impossible to change without detection.&lt;/p&gt;
&lt;h2&gt;
  
  
  The principle
&lt;/h2&gt;

&lt;p&gt;An agent audit trail must record the authority, not just the action. For every sensitive action it must capture:&lt;/p&gt;

&lt;ul&gt;
&lt;li&gt;the &lt;strong&gt;verified&lt;/strong&gt; identity of the agent that called,&lt;/li&gt;
&lt;li&gt;the person or agent the action was performed for,&lt;/li&gt;
&lt;li&gt;the exact permissions in force at that moment,&lt;/li&gt;
&lt;li&gt;the decision, allowed or denied, with the reason,&lt;/li&gt;
&lt;li&gt;and a value that proves the row was not edited later.&lt;/li&gt;
&lt;/ul&gt;

&lt;p&gt;When the record carries all of this, the auditor's question has a real answer. "Prove this deletion was authorized" becomes "here is the row: the disposition agent held &lt;code&gt;records:delete&lt;/code&gt;, the decision was allow, and the hash chain shows the row is unchanged." The review agent's illegal delete becomes a different row: decision &lt;strong&gt;denied&lt;/strong&gt;, reason &lt;strong&gt;insufficient scope&lt;/strong&gt;, action not performed.&lt;/p&gt;

&lt;p&gt;The rest of this article builds that record with the real code from a working demo. The demo is small, it runs locally, and it ships an end-to-end script that proves the whole story headless.&lt;/p&gt;
&lt;h2&gt;
  
  
  The fix: record the decision, not the claim
&lt;/h2&gt;

&lt;p&gt;The fix has one core idea. Before the system performs a sensitive action, it asks a separate authority to make a decision. That authority verifies the caller, checks the exact permissions in force, and returns &lt;em&gt;allow&lt;/em&gt; or &lt;em&gt;deny&lt;/em&gt;. The system then writes one row that captures the decision, the verified identity, and the permissions. It performs the action only on &lt;em&gt;allow&lt;/em&gt;.&lt;/p&gt;

&lt;p&gt;The demo uses four tools, and each one owns one job. This split matters, so here is the honest version of who does what:&lt;/p&gt;

&lt;ul&gt;
&lt;li&gt;
&lt;strong&gt;Kinde&lt;/strong&gt; issues and verifies each agent's identity and scopes. Kinde does not ship the audit layer.&lt;/li&gt;
&lt;li&gt;The &lt;strong&gt;kinde-convex-agent-auth&lt;/strong&gt; component makes the authorization decision and returns the result. It keeps its own append-only decision audit.&lt;/li&gt;
&lt;li&gt;
&lt;strong&gt;Convex&lt;/strong&gt; stores the replayable, hash-chained record the app builds from each decision, and it drives the user interface.&lt;/li&gt;
&lt;li&gt;
&lt;strong&gt;Langfuse&lt;/strong&gt; traces how the run executed: spans, timing, tool calls. It is not the audit record. A run's &lt;code&gt;correlationId&lt;/code&gt; links its Langfuse trace to its authority rows.&lt;/li&gt;
&lt;/ul&gt;

&lt;p&gt;Keep these apart. The app builds the authority record from the component's decision and stores it in Convex. &lt;a href="https://kinde.com/?utm_source=devto&amp;amp;utm_medium=content&amp;amp;utm_campaign=shola&amp;amp;campaignid=chatgptapp&amp;amp;network=&amp;amp;adgroup=&amp;amp;keyword=&amp;amp;matchtype=&amp;amp;creative=20&amp;amp;device=&amp;amp;adposition=" rel="noopener noreferrer"&gt;Kinde is the identity layer&lt;/a&gt;. The trace is not the authority record. Each tool answers one question, and no tool answers another's.&lt;/p&gt;

&lt;p&gt;&lt;a href="https://media2.dev.to/dynamic/image/width=800%2Cheight=%2Cfit=scale-down%2Cgravity=auto%2Cformat=auto/https%3A%2F%2Fdev-to-uploads.s3.us-east-2.amazonaws.com%2Fuploads%2Farticles%2Fmcm9sqc5inyf4aeed03f.png" class="article-body-image-wrapper"&gt;&lt;img src="https://media2.dev.to/dynamic/image/width=800%2Cheight=%2Cfit=scale-down%2Cgravity=auto%2Cformat=auto/https%3A%2F%2Fdev-to-uploads.s3.us-east-2.amazonaws.com%2Fuploads%2Farticles%2Fmcm9sqc5inyf4aeed03f.png" alt="An agent action first goes through authorize, which verifies the token and&lt;br&gt;
  intersects the scopes. On allow, the app performs the action and writes a row with&lt;br&gt;
  the identity, the decision, and the scopes. On deny, it performs nothing and&lt;br&gt;
  still writes a row with the reason. Either way, the row is hash-chained to the one&lt;br&gt;
  before it." width="800" height="122"&gt;&lt;/a&gt;&lt;/p&gt;
&lt;h3&gt;
  
  
  The authorization call
&lt;/h3&gt;

&lt;p&gt;Every sensitive action runs through one call: &lt;code&gt;authorize&lt;/code&gt;. The application passes the agent's token, the run identifier, the action, and the target. The component does three things:&lt;/p&gt;

&lt;ol&gt;
&lt;li&gt;It verifies the token. It reads the scopes the token carries and resolves the caller to a registered agent.&lt;/li&gt;
&lt;li&gt;It works out the effective scopes. These are the scopes the agent holds, the scopes its delegation allows, and the scopes in the token, combined by intersection. The result is the set of rights in force for this action.&lt;/li&gt;
&lt;li&gt;It returns a decision. If the effective scopes include the scope the action needs, the decision is &lt;em&gt;allow&lt;/em&gt;. If not, the decision is &lt;em&gt;deny&lt;/em&gt;, with a reason and the missing scope.&lt;/li&gt;
&lt;/ol&gt;

&lt;p&gt;An invalid token is a different case. A bad or expired token throws an error, which the application rejects with a 401. A valid token that lacks the right scope is not an error. It is a lawful &lt;em&gt;deny&lt;/em&gt;, and the system records it as one. The difference matters: a &lt;em&gt;deny&lt;/em&gt; is a normal, recorded outcome, not a crash.&lt;/p&gt;
&lt;h3&gt;
  
  
  The denial, step by step
&lt;/h3&gt;

&lt;p&gt;Here is the review agent's illegal delete, walked through the fixed system. The demo seeds named case files, and the run acts on the first two active ones. The review agent targets the first, &lt;em&gt;Halvorsen v. Meridian Logistics&lt;/em&gt;.&lt;/p&gt;

&lt;p&gt;The review agent holds two scopes: &lt;code&gt;records:read&lt;/code&gt; and &lt;code&gt;records:annotate&lt;/code&gt;. It does not hold &lt;code&gt;records:delete&lt;/code&gt;.&lt;/p&gt;

&lt;ol&gt;
&lt;li&gt;The application calls &lt;code&gt;authorize&lt;/code&gt; with the review agent's token and the action &lt;code&gt;records:delete&lt;/code&gt;.&lt;/li&gt;
&lt;li&gt;The component verifies the token. The caller is the registered review agent. Its effective scopes are &lt;code&gt;records:read&lt;/code&gt; and &lt;code&gt;records:annotate&lt;/code&gt;.&lt;/li&gt;
&lt;li&gt;The action &lt;code&gt;records:delete&lt;/code&gt; needs the scope &lt;code&gt;records:delete&lt;/code&gt;. The effective scopes do not include it.&lt;/li&gt;
&lt;li&gt;The decision is &lt;strong&gt;deny&lt;/strong&gt;. The reason is &lt;code&gt;insufficient_scope&lt;/code&gt;. The missing scope is &lt;code&gt;records:delete&lt;/code&gt;.&lt;/li&gt;
&lt;li&gt;The system does not delete the file. &lt;em&gt;Halvorsen v. Meridian Logistics&lt;/em&gt; stays active.&lt;/li&gt;
&lt;li&gt;The system writes one row: agent identity, the action, the decision &lt;em&gt;deny&lt;/em&gt;, the reason, the scopes in force, the missing scope, and the run identifier.&lt;/li&gt;
&lt;/ol&gt;

&lt;p&gt;The lawful delete by the disposition agent runs through the same call and returns &lt;em&gt;allow&lt;/em&gt;, because the disposition agent holds &lt;code&gt;records:delete&lt;/code&gt;. It targets the second file, &lt;em&gt;Project Cormorant — internal strategy memo&lt;/em&gt;, and the file is deleted. It produces a row too, with decision &lt;em&gt;allow&lt;/em&gt; and the scopes that permitted it.&lt;/p&gt;

&lt;p&gt;Now the two deletes are different rows. One says allow. One says deny. The auditor can tell them apart, because the authority is in the record.&lt;/p&gt;

&lt;p&gt;&lt;a href="https://media2.dev.to/dynamic/image/width=800%2Cheight=%2Cfit=scale-down%2Cgravity=auto%2Cformat=auto/https%3A%2F%2Fdev-to-uploads.s3.us-east-2.amazonaws.com%2Fuploads%2Farticles%2Fqjzujhook6q419f2x5l9.png" class="article-body-image-wrapper"&gt;&lt;img src="https://media2.dev.to/dynamic/image/width=800%2Cheight=%2Cfit=scale-down%2Cgravity=auto%2Cformat=auto/https%3A%2F%2Fdev-to-uploads.s3.us-east-2.amazonaws.com%2Fuploads%2Farticles%2Fqjzujhook6q419f2x5l9.png" alt="The provenance panel in enforced mode. A red DENY row for records:delete on " width="800" height="810"&gt;&lt;/a&gt;&lt;/p&gt;

&lt;p&gt;&lt;em&gt;The authority record in enforced mode. Each row shows the verified machine-to-machine subject (the raw Kinde identity, not a friendly name), the action, the file, and the scopes in force. The scopes identify the agent: &lt;code&gt;records:read&lt;/code&gt; + &lt;code&gt;records:annotate&lt;/code&gt; is the review agent, so its delete is denied; the disposition agent's scopes include &lt;code&gt;records:delete&lt;/code&gt;, so its delete is allowed. The denied file stays active; the allowed file is deleted.&lt;/em&gt;&lt;/p&gt;
&lt;h3&gt;
  
  
  One row per action
&lt;/h3&gt;

&lt;p&gt;The row the app builds from the component's decision is the authorized-action record. It carries the fields an auditor needs:&lt;/p&gt;

&lt;ul&gt;
&lt;li&gt;the organization the action belongs to,&lt;/li&gt;
&lt;li&gt;a sequence number, unique and in order within the organization,&lt;/li&gt;
&lt;li&gt;the run identifier, which also links to the Langfuse trace,&lt;/li&gt;
&lt;li&gt;the time,&lt;/li&gt;
&lt;li&gt;the agent's verified identity,&lt;/li&gt;
&lt;li&gt;the authority root, the person or agent the action was performed for,&lt;/li&gt;
&lt;li&gt;the effective scopes in force,&lt;/li&gt;
&lt;li&gt;the action, and the target it acted on,&lt;/li&gt;
&lt;li&gt;the decision, and on a deny, the reason and the missing scope,&lt;/li&gt;
&lt;li&gt;and two hash values that chain the row to the one before it.&lt;/li&gt;
&lt;/ul&gt;

&lt;p&gt;The last two fields make the record tamper-evident, which is the next piece.&lt;/p&gt;
&lt;h3&gt;
  
  
  The hash chain
&lt;/h3&gt;

&lt;p&gt;A record that anyone can edit is not proof. If a person can open the table and change a &lt;em&gt;deny&lt;/em&gt; to an &lt;em&gt;allow&lt;/em&gt;, the auditor cannot trust any row. So the rows form a chain.&lt;/p&gt;

&lt;p&gt;Each row carries the hash of the row before it, called &lt;code&gt;prevHash&lt;/code&gt;, and its own hash, called &lt;code&gt;rowHash&lt;/code&gt;. The system builds &lt;code&gt;rowHash&lt;/code&gt; from the row's own fields and the previous row's hash. The first row uses a fixed starting value. Every later row depends on every row before it.&lt;/p&gt;

&lt;p&gt;This has one useful result. If a person changes any field in any past row, that row's hash no longer matches, and every row after it no longer matches either. A single edit breaks the chain from that point on, and a check finds the exact row where the break starts.&lt;/p&gt;

&lt;p&gt;The demo has a &lt;strong&gt;Verify integrity&lt;/strong&gt; action. It walks the chain, recomputes each hash, and reports either "chain verified, N rows" or the sequence number where the chain breaks. This shows the rows are unchanged, rather than promising it.&lt;/p&gt;

&lt;p&gt;&lt;a href="https://media2.dev.to/dynamic/image/width=800%2Cheight=%2Cfit=scale-down%2Cgravity=auto%2Cformat=auto/https%3A%2F%2Fdev-to-uploads.s3.us-east-2.amazonaws.com%2Fuploads%2Farticles%2Fn7flch05fjffpqmno6s8.png" class="article-body-image-wrapper"&gt;&lt;img src="https://media2.dev.to/dynamic/image/width=800%2Cheight=%2Cfit=scale-down%2Cgravity=auto%2Cformat=auto/https%3A%2F%2Fdev-to-uploads.s3.us-east-2.amazonaws.com%2Fuploads%2Farticles%2Fn7flch05fjffpqmno6s8.png" alt="The provenance panel after clicking Verify integrity. The DENY and ALLOW rows are shown, with a green result line below: " width="800" height="810"&gt;&lt;/a&gt;&lt;/p&gt;

&lt;p&gt;&lt;em&gt;After clicking **Verify integrity&lt;/em&gt;&lt;em&gt;, the check walks the chain and reports "Chain verified — 2 rows, unbroken." A changed row would report the sequence number where the chain breaks instead.&lt;/em&gt;&lt;/p&gt;

&lt;p&gt;The diagram below shows the fixed path. The action goes through &lt;code&gt;authorize&lt;/code&gt; first. The decision and the identity go into the row. The row chains to the row before it.&lt;/p&gt;

&lt;p&gt;&lt;a href="https://media2.dev.to/dynamic/image/width=800%2Cheight=%2Cfit=scale-down%2Cgravity=auto%2Cformat=auto/https%3A%2F%2Fdev-to-uploads.s3.us-east-2.amazonaws.com%2Fuploads%2Farticles%2F2o9yrkqy4d966gtqwnh1.png" class="article-body-image-wrapper"&gt;&lt;img src="https://media2.dev.to/dynamic/image/width=800%2Cheight=%2Cfit=scale-down%2Cgravity=auto%2Cformat=auto/https%3A%2F%2Fdev-to-uploads.s3.us-east-2.amazonaws.com%2Fuploads%2Farticles%2F2o9yrkqy4d966gtqwnh1.png" alt="Kinde issues and verifies the agent's identity and scopes. The&lt;br&gt;
  kinde-convex-agent-auth component turns that into an allow-or-deny decision. The&lt;br&gt;
  Convex app builds the authority row and hash-chains it. Langfuse traces how the&lt;br&gt;
  run executed, linked to the row by the same run id, but it is not the authority&lt;br&gt;
  record." width="800" height="1261"&gt;&lt;/a&gt;&lt;/p&gt;
&lt;h2&gt;
  
  
  Long-term auditability and telemetry
&lt;/h2&gt;

&lt;p&gt;An audit trail is useful during an incident. It also needs to stay useful after the incident is over. For AI agents, that means retaining enough telemetry to reconstruct what the agent did, which identity it acted under, what authorization decision allowed or refused the action, and how the action relates to the surrounding agent run.&lt;/p&gt;

&lt;p&gt;The chain and the lake solve different problems: the chain gives integrity at write time. A long-retention security data lake, such as &lt;a href="https://scanner.dev/" rel="noopener noreferrer"&gt;Scanner&lt;/a&gt;, gives durability and queryability at read time — canonical rows kept unsampled for the full compliance window, searchable for years, with MCP access so an agent can investigate the same authority trail it writes. Where these rows live long-term, and how you query them at agent volume, is a decision criterion when evaluating an AI SOC or agentic security platform, not an afterthought.&lt;/p&gt;
&lt;h2&gt;
  
  
  Why an auditor would accept this record
&lt;/h2&gt;

&lt;p&gt;A custom log format does not help an auditor. Auditors work from known frameworks, and they trust records that match practice they already recognize. The design in this article aligns with established standards on every point.&lt;/p&gt;
&lt;h3&gt;
  
  
  The fields align with NIST 800-53
&lt;/h3&gt;

&lt;p&gt;The NIST 800-53 catalog is the control set most audit programs build on. Its audit family, the AU controls, defines what an event record should hold and how to protect it. The authorized-action row lines up with three of them:&lt;/p&gt;

&lt;ul&gt;
&lt;li&gt;
&lt;strong&gt;AU-3, content of audit records.&lt;/strong&gt; AU-3 lists the fields an event should carry: the type of event, when it happened, where, the source, the outcome, and the identity involved. The row carries each of these. AU-3(1) adds "access control or flow control rules invoked," which is exactly the effective scopes and the decision the row records.&lt;/li&gt;
&lt;li&gt;
&lt;strong&gt;AU-9, protection of audit information.&lt;/strong&gt; AU-9 requires that audit records resist change. The hash chain supports this goal: an edited row breaks the chain, so a naive change cannot pass without detection. The chain gives tamper-evidence, not access control, so it is one part of an AU-9 story, not the whole of it.&lt;/li&gt;
&lt;li&gt;
&lt;strong&gt;AU-10, non-repudiation.&lt;/strong&gt; AU-10 requires that an actor cannot later deny an action. The row supports this by binding a verified identity to each decision. Full non-repudiation also needs a signature on the row, which this demo does not add. See the limits below.&lt;/li&gt;
&lt;/ul&gt;
&lt;h3&gt;
  
  
  The authority model follows RFC 8693
&lt;/h3&gt;

&lt;p&gt;RFC 8693 is the OAuth 2.0 Token Exchange standard. It defines how one party acts on behalf of another, and it gives the &lt;code&gt;act&lt;/code&gt; claim to name the acting party in a delegation. The row follows this shape: it names the actor and the authority root.&lt;/p&gt;

&lt;p&gt;Be precise about what this demo records. Each agent acts on its own machine-to-machine identity, so the row's authority root is the agent itself, not a human. The component also supports a user-rooted delegation, where a person delegates a subset of scopes to an agent and the row roots the authority in that person. The demo does not exercise that path. The effective-scope intersection still follows the standard's rule either way: a delegated actor cannot hold more than the party it acts for.&lt;/p&gt;
&lt;h3&gt;
  
  
  The row shape maps to OCSF
&lt;/h3&gt;

&lt;p&gt;The Open Cybersecurity Schema Framework, OCSF, is a vendor-neutral schema for security events. Its "API Activity" class covers create, read, update, and delete calls, the same class OCSF uses for AWS CloudTrail events. Its "Actor" object names the user, role, or process behind an activity. The row uses the same shape: an actor, an action, a target, an outcome, and a time. A security team can read the row into its existing tools without a custom parser.&lt;/p&gt;
&lt;h3&gt;
  
  
  The tamper-evidence maps to known practice
&lt;/h3&gt;

&lt;p&gt;Two standards support the hash chain:&lt;/p&gt;

&lt;ul&gt;
&lt;li&gt;
&lt;strong&gt;RFC 8785, JSON Canonicalization Scheme (JCS).&lt;/strong&gt; A hash is only stable if the input is serialized the same way every time. JCS defines one deterministic way to serialize JSON, so the same row always produces the same bytes and the same hash. The demo canonicalizes each row with JCS before it hashes. Many do-it-yourself audit logs skip this step and get unstable hashes.&lt;/li&gt;
&lt;li&gt;
&lt;strong&gt;Chain of custody, as used by AWS CloudTrail.&lt;/strong&gt; CloudTrail protects its log files with a hash chain, where each file references the hash of the one before it. The demo uses the same primitive at the row level. This pattern also supports common audit programs, such as SOC 2 and PCI DSS, which require that log records be protected from change.&lt;/li&gt;
&lt;/ul&gt;
&lt;h3&gt;
  
  
  What this demo does not do
&lt;/h3&gt;

&lt;p&gt;An honest record states its own limits. The demo uses a SHA-256 hash chain inside one Convex deployment. It does not sign rows with a private key, and it does not anchor the chain outside the system. So a person who controls the store could rewrite the whole chain and recompute every hash, and the internal check would still pass. A production system that must resist that attacker would go further:&lt;/p&gt;

&lt;ul&gt;
&lt;li&gt;
&lt;strong&gt;Merkle inclusion proofs&lt;/strong&gt;, so a single row can be proven without replaying the whole chain.&lt;/li&gt;
&lt;li&gt;
&lt;strong&gt;External root anchoring&lt;/strong&gt;, where the chain's current hash is published to a separate system on a schedule, so the party that runs the store cannot rewrite history unseen.&lt;/li&gt;
&lt;li&gt;
&lt;strong&gt;Separation of duties&lt;/strong&gt;, so the person who can change the system cannot also control the roots.&lt;/li&gt;
&lt;/ul&gt;

&lt;p&gt;Standards such as SCITT describe this kind of transparency for supply-chain and attestation records. The demo does not implement them. It implements the core chain, and it names the next step. An auditor accepts a record that states its own limits, and rejects one that overclaims.&lt;/p&gt;
&lt;h2&gt;
  
  
  Building it
&lt;/h2&gt;

&lt;p&gt;The demo is a small monorepo. The web app holds the Convex functions and the user interface. A separate agents package holds the agent graph. A third package is the only way the agents reach the app. This section shows the real code for the parts that matter: the mode switch, the authorization call, the provenance row, the hash chain, and the trust boundary.&lt;/p&gt;
&lt;h3&gt;
  
  
  The stack, and the job of each part
&lt;/h3&gt;

&lt;ul&gt;
&lt;li&gt;
&lt;strong&gt;Next.js&lt;/strong&gt; serves the web app and the demo page.&lt;/li&gt;
&lt;li&gt;
&lt;strong&gt;Convex&lt;/strong&gt; holds the database and the server functions. It stores the records, the blind log, and the provenance chain, and it drives the interface in real time.&lt;/li&gt;
&lt;li&gt;
&lt;strong&gt;Kinde&lt;/strong&gt; issues and verifies each agent's identity and scopes. Each agent is a separate &lt;a href="https://kinde.com/?utm_source=devto&amp;amp;utm_medium=content&amp;amp;utm_campaign=shola&amp;amp;campaignid=chatgptapp&amp;amp;network=&amp;amp;adgroup=&amp;amp;keyword=&amp;amp;matchtype=&amp;amp;creative=20&amp;amp;device=&amp;amp;adposition=" rel="noopener noreferrer"&gt;Kinde machine-to-machine application&lt;/a&gt;, so each has its own identity.&lt;/li&gt;
&lt;li&gt;
&lt;strong&gt;kinde-convex-agent-auth&lt;/strong&gt; is the authorization component. It verifies the caller, makes the decision, and gives back the result the app records.&lt;/li&gt;
&lt;li&gt;
&lt;strong&gt;Mastra&lt;/strong&gt; runs the agent graph: intake, then review, then disposition.&lt;/li&gt;
&lt;li&gt;
&lt;strong&gt;Langfuse&lt;/strong&gt; traces how the run executed. It is not the audit record.&lt;/li&gt;
&lt;/ul&gt;

&lt;p&gt;&lt;a href="https://media2.dev.to/dynamic/image/width=800%2Cheight=%2Cfit=scale-down%2Cgravity=auto%2Cformat=auto/https%3A%2F%2Fdev-to-uploads.s3.us-east-2.amazonaws.com%2Fuploads%2Farticles%2Fq7ah054ku781egxvnmr9.png" class="article-body-image-wrapper"&gt;&lt;img src="https://media2.dev.to/dynamic/image/width=800%2Cheight=%2Cfit=scale-down%2Cgravity=auto%2Cformat=auto/https%3A%2F%2Fdev-to-uploads.s3.us-east-2.amazonaws.com%2Fuploads%2Farticles%2Fq7ah054ku781egxvnmr9.png" alt="The top of the demo page: the " width="800" height="860"&gt;&lt;/a&gt;&lt;/p&gt;

&lt;p&gt;&lt;em&gt;The demo page. The scenario states the problem in plain language, the legend names the three agents and what each may do, and the "Demo control" flips the one server mode. Login is optional — an anonymous visitor gets the full page.&lt;/em&gt;&lt;/p&gt;
&lt;h3&gt;
  
  
  The two modes, decided on the server
&lt;/h3&gt;

&lt;p&gt;The demo has two modes. Broken mode writes the blind log and checks nothing. Enforced mode verifies the agent and records the decision. The mode is a server value. A request can never choose its own mode, because that would let an agent pick the mode that lets its action through.&lt;/p&gt;

&lt;p&gt;The &lt;code&gt;resolveAuthzMode&lt;/code&gt; function reads the mode from one global setting, then from the deployment environment, and never from the request:&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight typescript"&gt;&lt;code&gt;&lt;span class="c1"&gt;// apps/web/convex/authzMode.ts&lt;/span&gt;
&lt;span class="k"&gt;export&lt;/span&gt; &lt;span class="k"&gt;async&lt;/span&gt; &lt;span class="kd"&gt;function&lt;/span&gt; &lt;span class="nf"&gt;resolveAuthzMode&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="nx"&gt;ctx&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="nx"&gt;QueryCtx&lt;/span&gt;&lt;span class="p"&gt;):&lt;/span&gt; &lt;span class="nb"&gt;Promise&lt;/span&gt;&lt;span class="o"&gt;&amp;lt;&lt;/span&gt;&lt;span class="nx"&gt;AuthzMode&lt;/span&gt;&lt;span class="o"&gt;&amp;gt;&lt;/span&gt; &lt;span class="p"&gt;{&lt;/span&gt;
  &lt;span class="kd"&gt;const&lt;/span&gt; &lt;span class="nx"&gt;row&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="k"&gt;await&lt;/span&gt; &lt;span class="nx"&gt;ctx&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nx"&gt;db&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nf"&gt;query&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="dl"&gt;'&lt;/span&gt;&lt;span class="s1"&gt;demoSettings&lt;/span&gt;&lt;span class="dl"&gt;'&lt;/span&gt;&lt;span class="p"&gt;).&lt;/span&gt;&lt;span class="nf"&gt;first&lt;/span&gt;&lt;span class="p"&gt;();&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;row&lt;/span&gt; &lt;span class="o"&gt;!==&lt;/span&gt; &lt;span class="kc"&gt;null&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="nx"&gt;row&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nx"&gt;authzMode&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="nx"&gt;process&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nx"&gt;env&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nx"&gt;AUTHZ_MODE&lt;/span&gt; &lt;span class="o"&gt;===&lt;/span&gt; &lt;span class="dl"&gt;'&lt;/span&gt;&lt;span class="s1"&gt;enforced&lt;/span&gt;&lt;span class="dl"&gt;'&lt;/span&gt; &lt;span class="p"&gt;?&lt;/span&gt; &lt;span class="dl"&gt;'&lt;/span&gt;&lt;span class="s1"&gt;enforced&lt;/span&gt;&lt;span class="dl"&gt;'&lt;/span&gt; &lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="dl"&gt;'&lt;/span&gt;&lt;span class="s1"&gt;broken&lt;/span&gt;&lt;span class="dl"&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;The demo page has a toggle that flips this one global value, so a reader can switch modes and watch the result change. The toggle sets the global mode. It does not attach a mode to a request. The action path always reads the resolved global value.&lt;/p&gt;

&lt;h3&gt;
  
  
  The single action entry point
&lt;/h3&gt;

&lt;p&gt;Every record action goes through one function, &lt;code&gt;performAction&lt;/code&gt;. It reads the mode first. In broken mode it writes the blind row. In enforced mode it runs the full path: verify, start the run instance, authorize, then record.&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight typescript"&gt;&lt;code&gt;&lt;span class="c1"&gt;// apps/web/convex/agentActions.ts&lt;/span&gt;
&lt;span class="kd"&gt;const&lt;/span&gt; &lt;span class="nx"&gt;mode&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="k"&gt;await&lt;/span&gt; &lt;span class="nx"&gt;ctx&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nf"&gt;runQuery&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="nx"&gt;internal&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nx"&gt;authzMode&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nx"&gt;readAuthzMode&lt;/span&gt;&lt;span class="p"&gt;);&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;mode&lt;/span&gt; &lt;span class="o"&gt;===&lt;/span&gt; &lt;span class="dl"&gt;'&lt;/span&gt;&lt;span class="s1"&gt;broken&lt;/span&gt;&lt;span class="dl"&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="k"&gt;await&lt;/span&gt; &lt;span class="nx"&gt;ctx&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nf"&gt;runMutation&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="nx"&gt;internal&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nx"&gt;agentActions&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nx"&gt;performBroken&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="p"&gt;{&lt;/span&gt;
    &lt;span class="na"&gt;orgCode&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="nx"&gt;args&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nx"&gt;orgCode&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt;
    &lt;span class="na"&gt;actorAgentId&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="nx"&gt;args&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nx"&gt;actorAgentId&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt;
    &lt;span class="na"&gt;action&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="nx"&gt;args&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nx"&gt;action&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt;
    &lt;span class="na"&gt;recordId&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="nx"&gt;args&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nx"&gt;recordId&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt;
    &lt;span class="na"&gt;title&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="nx"&gt;args&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nx"&gt;title&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt;
    &lt;span class="na"&gt;kind&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="nx"&gt;args&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nx"&gt;kind&lt;/span&gt;
  &lt;span class="p"&gt;});&lt;/span&gt;
&lt;span class="p"&gt;}&lt;/span&gt;

&lt;span class="c1"&gt;// ENFORCED. An invalid or missing token throws. A denial does NOT throw —&lt;/span&gt;
&lt;span class="c1"&gt;// it is returned as decision.allowed === false and recorded.&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;args&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nx"&gt;token&lt;/span&gt; &lt;span class="o"&gt;===&lt;/span&gt; &lt;span class="kc"&gt;undefined&lt;/span&gt; &lt;span class="o"&gt;||&lt;/span&gt; &lt;span class="nx"&gt;args&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nx"&gt;token&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nx"&gt;length&lt;/span&gt; &lt;span class="o"&gt;===&lt;/span&gt; &lt;span class="mi"&gt;0&lt;/span&gt;&lt;span class="p"&gt;)&lt;/span&gt; &lt;span class="p"&gt;{&lt;/span&gt;
  &lt;span class="k"&gt;throw&lt;/span&gt; &lt;span class="k"&gt;new&lt;/span&gt; &lt;span class="nc"&gt;Error&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="dl"&gt;'&lt;/span&gt;&lt;span class="s1"&gt;enforced: missing bearer token&lt;/span&gt;&lt;span class="dl"&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;The comment states the rule that matters. A bad token is an error. A lawful &lt;em&gt;deny&lt;/em&gt; is not an error.&lt;/p&gt;

&lt;h3&gt;
  
  
  Verify, then authorize
&lt;/h3&gt;

&lt;p&gt;Enforced mode does three things before it records anything. It verifies the token, it starts a run instance for the agent, and it authorizes the action.&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight typescript"&gt;&lt;code&gt;&lt;span class="c1"&gt;// apps/web/convex/agentActions.ts&lt;/span&gt;
&lt;span class="c1"&gt;// 1. Verify the token to resolve the registered agent (throws if invalid).&lt;/span&gt;
&lt;span class="kd"&gt;const&lt;/span&gt; &lt;span class="nx"&gt;verified&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="k"&gt;await&lt;/span&gt; &lt;span class="nx"&gt;agentAuth&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nf"&gt;verifyCaller&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="nx"&gt;ctx&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="nx"&gt;args&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nx"&gt;token&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="p"&gt;{&lt;/span&gt;
  &lt;span class="na"&gt;expectedOrgCode&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="nx"&gt;args&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nx"&gt;orgCode&lt;/span&gt;
&lt;span class="p"&gt;});&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;verified&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nx"&gt;agentId&lt;/span&gt; &lt;span class="o"&gt;===&lt;/span&gt; &lt;span class="kc"&gt;null&lt;/span&gt;&lt;span class="p"&gt;)&lt;/span&gt; &lt;span class="p"&gt;{&lt;/span&gt;
  &lt;span class="k"&gt;throw&lt;/span&gt; &lt;span class="k"&gt;new&lt;/span&gt; &lt;span class="nc"&gt;Error&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="dl"&gt;'&lt;/span&gt;&lt;span class="s1"&gt;enforced: token maps to no registered agent&lt;/span&gt;&lt;span class="dl"&gt;'&lt;/span&gt;&lt;span class="p"&gt;);&lt;/span&gt;
&lt;span class="p"&gt;}&lt;/span&gt;

&lt;span class="c1"&gt;// 2. Start the run instance, only after token verification.&lt;/span&gt;
&lt;span class="kd"&gt;const&lt;/span&gt; &lt;span class="nx"&gt;instanceId&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="k"&gt;await&lt;/span&gt; &lt;span class="nx"&gt;ctx&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nf"&gt;runMutation&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="nx"&gt;internal&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nx"&gt;agentAuth&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nx"&gt;startInstance&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="p"&gt;{&lt;/span&gt;
  &lt;span class="na"&gt;agentId&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="nx"&gt;verified&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nx"&gt;agentId&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt;
  &lt;span class="na"&gt;runId&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="nx"&gt;args&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nx"&gt;correlationId&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="nx"&gt;verified&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nx"&gt;agentId&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="na"&gt;actingForSubject&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="nx"&gt;args&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nx"&gt;actingForSubject&lt;/span&gt; &lt;span class="o"&gt;??&lt;/span&gt; &lt;span class="nx"&gt;verified&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nx"&gt;subject&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt;
  &lt;span class="na"&gt;orgCode&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="nx"&gt;args&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nx"&gt;orgCode&lt;/span&gt;
&lt;span class="p"&gt;});&lt;/span&gt;

&lt;span class="c1"&gt;// 3. Authorize this action for the instance. authorize() (never authz.can)&lt;/span&gt;
&lt;span class="c1"&gt;//    threads the verified caller in, so the decision is bound to this agent.&lt;/span&gt;
&lt;span class="kd"&gt;const&lt;/span&gt; &lt;span class="p"&gt;{&lt;/span&gt;&lt;span class="nx"&gt;caller&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="nx"&gt;decision&lt;/span&gt;&lt;span class="p"&gt;}&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="k"&gt;await&lt;/span&gt; &lt;span class="nx"&gt;agentAuth&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nf"&gt;authorize&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="nx"&gt;ctx&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="nx"&gt;args&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nx"&gt;token&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="p"&gt;{&lt;/span&gt;
  &lt;span class="na"&gt;instanceId&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="nx"&gt;instanceId&lt;/span&gt; &lt;span class="k"&gt;as&lt;/span&gt; &lt;span class="nx"&gt;CanArgs&lt;/span&gt;&lt;span class="p"&gt;[&lt;/span&gt;&lt;span class="dl"&gt;'&lt;/span&gt;&lt;span class="s1"&gt;instanceId&lt;/span&gt;&lt;span class="dl"&gt;'&lt;/span&gt;&lt;span class="p"&gt;],&lt;/span&gt;
  &lt;span class="na"&gt;action&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="nx"&gt;args&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nx"&gt;action&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt;
  &lt;span class="na"&gt;enforceTokenScopes&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="kc"&gt;true&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt;
  &lt;span class="na"&gt;requireOrgCode&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="kc"&gt;true&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt;
  &lt;span class="p"&gt;...(&lt;/span&gt;&lt;span class="nx"&gt;args&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nx"&gt;recordId&lt;/span&gt; &lt;span class="o"&gt;===&lt;/span&gt; &lt;span class="kc"&gt;undefined&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;span class="na"&gt;resource&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="nx"&gt;args&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nx"&gt;recordId&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;Two options in the &lt;code&gt;authorize&lt;/code&gt; call carry the weight. &lt;code&gt;enforceTokenScopes: true&lt;/code&gt; makes the decision use the scopes the token actually carries, so an agent cannot claim a scope its token does not hold. &lt;code&gt;requireOrgCode: true&lt;/code&gt; binds the decision to the organization, so a token for one tenant cannot act on another.&lt;/p&gt;

&lt;p&gt;The call returns &lt;code&gt;caller&lt;/code&gt; and &lt;code&gt;decision&lt;/code&gt;. The caller is the verified identity and its scopes. The decision is &lt;em&gt;allow&lt;/em&gt; or &lt;em&gt;deny&lt;/em&gt;, with a reason and the missing scope on a &lt;em&gt;deny&lt;/em&gt;. The app records both.&lt;/p&gt;

&lt;h3&gt;
  
  
  One row, allow or deny
&lt;/h3&gt;

&lt;p&gt;The commit step performs the action only on &lt;em&gt;allow&lt;/em&gt;, then writes one provenance row either way. A &lt;em&gt;deny&lt;/em&gt; performs nothing, and it still produces a row, because a denied attempt is part of the audit trail.&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight typescript"&gt;&lt;code&gt;&lt;span class="c1"&gt;// apps/web/convex/agentActions.ts&lt;/span&gt;
&lt;span class="kd"&gt;const&lt;/span&gt; &lt;span class="nx"&gt;resourceId&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="kr"&gt;string&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="nx"&gt;args&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nx"&gt;allowed&lt;/span&gt;
  &lt;span class="p"&gt;?&lt;/span&gt; &lt;span class="k"&gt;await&lt;/span&gt; &lt;span class="nf"&gt;applyAction&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="nx"&gt;ctx&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="nx"&gt;args&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nx"&gt;orgCode&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="nx"&gt;args&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nx"&gt;action&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="nx"&gt;args&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nx"&gt;recordId&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="nx"&gt;args&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nx"&gt;title&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="nx"&gt;args&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nx"&gt;kind&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="nx"&gt;args&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nx"&gt;recordId&lt;/span&gt; &lt;span class="o"&gt;??&lt;/span&gt; &lt;span class="dl"&gt;''&lt;/span&gt;&lt;span class="p"&gt;);&lt;/span&gt;

&lt;span class="k"&gt;await&lt;/span&gt; &lt;span class="nf"&gt;appendProvenanceRow&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="nx"&gt;ctx&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="p"&gt;{&lt;/span&gt;
  &lt;span class="na"&gt;orgCode&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="nx"&gt;args&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nx"&gt;orgCode&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt;
  &lt;span class="na"&gt;correlationId&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="nx"&gt;args&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nx"&gt;correlationId&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt;
  &lt;span class="na"&gt;ts&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="nb"&gt;Date&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nf"&gt;now&lt;/span&gt;&lt;span class="p"&gt;(),&lt;/span&gt;
  &lt;span class="na"&gt;actorAgentId&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="nx"&gt;args&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nx"&gt;actorAgentId&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt;
  &lt;span class="na"&gt;actorSub&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="nx"&gt;args&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nx"&gt;actorSub&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt;
  &lt;span class="na"&gt;authorityRootKind&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="nx"&gt;args&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nx"&gt;authorityRootKind&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt;
  &lt;span class="na"&gt;authorityRootSub&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="nx"&gt;args&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nx"&gt;authorityRootSub&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt;
  &lt;span class="na"&gt;delegationId&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="nx"&gt;args&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nx"&gt;delegationId&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt;
  &lt;span class="na"&gt;effectiveScopes&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="nx"&gt;args&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nx"&gt;effectiveScopes&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt;
  &lt;span class="na"&gt;action&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="nx"&gt;args&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nx"&gt;action&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt;
  &lt;span class="na"&gt;resourceType&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="dl"&gt;'&lt;/span&gt;&lt;span class="s1"&gt;records&lt;/span&gt;&lt;span class="dl"&gt;'&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt;
  &lt;span class="nx"&gt;resourceId&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt;
  &lt;span class="na"&gt;decision&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="nx"&gt;args&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nx"&gt;allowed&lt;/span&gt; &lt;span class="p"&gt;?&lt;/span&gt; &lt;span class="dl"&gt;'&lt;/span&gt;&lt;span class="s1"&gt;allow&lt;/span&gt;&lt;span class="dl"&gt;'&lt;/span&gt; &lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="dl"&gt;'&lt;/span&gt;&lt;span class="s1"&gt;deny&lt;/span&gt;&lt;span class="dl"&gt;'&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt;
  &lt;span class="p"&gt;...(&lt;/span&gt;&lt;span class="nx"&gt;args&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nx"&gt;denyReason&lt;/span&gt; &lt;span class="o"&gt;===&lt;/span&gt; &lt;span class="kc"&gt;undefined&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;span class="na"&gt;denyReason&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="nx"&gt;args&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nx"&gt;denyReason&lt;/span&gt;&lt;span class="p"&gt;}),&lt;/span&gt;
  &lt;span class="p"&gt;...(&lt;/span&gt;&lt;span class="nx"&gt;args&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nx"&gt;requiredScopes&lt;/span&gt; &lt;span class="o"&gt;===&lt;/span&gt; &lt;span class="kc"&gt;undefined&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;span class="na"&gt;requiredScopes&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="nx"&gt;args&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nx"&gt;requiredScopes&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;Compare this with the broken path, which writes a row with no authority at all:&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight typescript"&gt;&lt;code&gt;&lt;span class="c1"&gt;// apps/web/convex/agentActions.ts — broken mode&lt;/span&gt;
&lt;span class="k"&gt;await&lt;/span&gt; &lt;span class="nx"&gt;ctx&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nx"&gt;db&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nf"&gt;insert&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="dl"&gt;'&lt;/span&gt;&lt;span class="s1"&gt;activityLog&lt;/span&gt;&lt;span class="dl"&gt;'&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="p"&gt;{&lt;/span&gt;
  &lt;span class="na"&gt;orgCode&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="nx"&gt;args&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nx"&gt;orgCode&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt;
  &lt;span class="na"&gt;actorAgentId&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="nx"&gt;args&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nx"&gt;actorAgentId&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt;
  &lt;span class="na"&gt;action&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="nx"&gt;args&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nx"&gt;action&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt;
  &lt;span class="na"&gt;resourceType&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="dl"&gt;'&lt;/span&gt;&lt;span class="s1"&gt;records&lt;/span&gt;&lt;span class="dl"&gt;'&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt;
  &lt;span class="nx"&gt;resourceId&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt;
  &lt;span class="na"&gt;ts&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="nb"&gt;Date&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nf"&gt;now&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 broken row has an actor label, an action, a target, and a time. It has no verified identity, no scopes, and no decision. That is the blind log from the start of this article.&lt;/p&gt;

&lt;p&gt;&lt;a href="https://media2.dev.to/dynamic/image/width=800%2Cheight=%2Cfit=scale-down%2Cgravity=auto%2Cformat=auto/https%3A%2F%2Fdev-to-uploads.s3.us-east-2.amazonaws.com%2Fuploads%2Farticles%2Fl97xtqkwegls7cpd19w5.png" class="article-body-image-wrapper"&gt;&lt;img src="https://media2.dev.to/dynamic/image/width=800%2Cheight=%2Cfit=scale-down%2Cgravity=auto%2Cformat=auto/https%3A%2F%2Fdev-to-uploads.s3.us-east-2.amazonaws.com%2Fuploads%2Farticles%2Fl97xtqkwegls7cpd19w5.png" alt="The blind activityLog panel in broken mode. Two records:delete rows, one by disposition on " width="800" height="810"&gt;&lt;/a&gt;&lt;/p&gt;

&lt;p&gt;&lt;em&gt;The blind log in broken mode. Both deletes land as the same shape — an actor label, the action, the file, a time — each marked "no authority recorded." Nothing on either row says which delete was allowed. The authority panel below stays empty.&lt;/em&gt;&lt;/p&gt;

&lt;h3&gt;
  
  
  The hash chain
&lt;/h3&gt;

&lt;p&gt;The provenance row carries two hashes, &lt;code&gt;prevHash&lt;/code&gt; and &lt;code&gt;rowHash&lt;/code&gt;. The append function canonicalizes the row with RFC 8785 JCS before it hashes, so the same row always produces the same bytes:&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight typescript"&gt;&lt;code&gt;&lt;span class="c1"&gt;// apps/web/convex/provenance.ts&lt;/span&gt;
&lt;span class="k"&gt;async&lt;/span&gt; &lt;span class="kd"&gt;function&lt;/span&gt; &lt;span class="nf"&gt;computeRowHash&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;
  &lt;span class="nx"&gt;rowBody&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="nb"&gt;Record&lt;/span&gt;&lt;span class="o"&gt;&amp;lt;&lt;/span&gt;&lt;span class="kr"&gt;string&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="nx"&gt;unknown&lt;/span&gt;&lt;span class="o"&gt;&amp;gt;&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt;
  &lt;span class="nx"&gt;prevHash&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="kr"&gt;string&lt;/span&gt;
&lt;span class="p"&gt;):&lt;/span&gt; &lt;span class="nb"&gt;Promise&lt;/span&gt;&lt;span class="o"&gt;&amp;lt;&lt;/span&gt;&lt;span class="kr"&gt;string&lt;/span&gt;&lt;span class="o"&gt;&amp;gt;&lt;/span&gt; &lt;span class="p"&gt;{&lt;/span&gt;
  &lt;span class="kd"&gt;const&lt;/span&gt; &lt;span class="nx"&gt;canonical&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="nf"&gt;canonicalize&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="nx"&gt;rowBody&lt;/span&gt;&lt;span class="p"&gt;);&lt;/span&gt; &lt;span class="c1"&gt;// RFC 8785 JCS, never JSON.stringify&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;canonical&lt;/span&gt; &lt;span class="o"&gt;===&lt;/span&gt; &lt;span class="kc"&gt;undefined&lt;/span&gt;&lt;span class="p"&gt;)&lt;/span&gt; &lt;span class="p"&gt;{&lt;/span&gt;
    &lt;span class="k"&gt;throw&lt;/span&gt; &lt;span class="k"&gt;new&lt;/span&gt; &lt;span class="nc"&gt;Error&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="dl"&gt;'&lt;/span&gt;&lt;span class="s1"&gt;provenance: canonicalization failed&lt;/span&gt;&lt;span class="dl"&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="k"&gt;await&lt;/span&gt; &lt;span class="nf"&gt;sha256Hex&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="nx"&gt;canonical&lt;/span&gt; &lt;span class="o"&gt;+&lt;/span&gt; &lt;span class="nx"&gt;prevHash&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 append reads the last row inside the same transaction, so the chain stays gapless and ordered even under concurrent writes:&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight typescript"&gt;&lt;code&gt;&lt;span class="c1"&gt;// apps/web/convex/provenance.ts&lt;/span&gt;
&lt;span class="kd"&gt;const&lt;/span&gt; &lt;span class="nx"&gt;last&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="k"&gt;await&lt;/span&gt; &lt;span class="nx"&gt;ctx&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nx"&gt;db&lt;/span&gt;
  &lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nf"&gt;query&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="dl"&gt;'&lt;/span&gt;&lt;span class="s1"&gt;provenance&lt;/span&gt;&lt;span class="dl"&gt;'&lt;/span&gt;&lt;span class="p"&gt;)&lt;/span&gt;
  &lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nf"&gt;withIndex&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="dl"&gt;'&lt;/span&gt;&lt;span class="s1"&gt;by_org_seq&lt;/span&gt;&lt;span class="dl"&gt;'&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="nx"&gt;q&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;q&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nf"&gt;eq&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="dl"&gt;'&lt;/span&gt;&lt;span class="s1"&gt;orgCode&lt;/span&gt;&lt;span class="dl"&gt;'&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="nx"&gt;input&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nx"&gt;orgCode&lt;/span&gt;&lt;span class="p"&gt;))&lt;/span&gt;
  &lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nf"&gt;order&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="dl"&gt;'&lt;/span&gt;&lt;span class="s1"&gt;desc&lt;/span&gt;&lt;span class="dl"&gt;'&lt;/span&gt;&lt;span class="p"&gt;)&lt;/span&gt;
  &lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nf"&gt;first&lt;/span&gt;&lt;span class="p"&gt;();&lt;/span&gt;
&lt;span class="kd"&gt;const&lt;/span&gt; &lt;span class="nx"&gt;seq&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="nx"&gt;last&lt;/span&gt; &lt;span class="o"&gt;===&lt;/span&gt; &lt;span class="kc"&gt;null&lt;/span&gt; &lt;span class="p"&gt;?&lt;/span&gt; &lt;span class="mi"&gt;0&lt;/span&gt; &lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="nx"&gt;last&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nx"&gt;seq&lt;/span&gt; &lt;span class="o"&gt;+&lt;/span&gt; &lt;span class="mi"&gt;1&lt;/span&gt;&lt;span class="p"&gt;;&lt;/span&gt;
&lt;span class="kd"&gt;const&lt;/span&gt; &lt;span class="nx"&gt;prevHash&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="nx"&gt;last&lt;/span&gt; &lt;span class="o"&gt;===&lt;/span&gt; &lt;span class="kc"&gt;null&lt;/span&gt; &lt;span class="p"&gt;?&lt;/span&gt; &lt;span class="nx"&gt;GENESIS&lt;/span&gt; &lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="nx"&gt;last&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nx"&gt;rowHash&lt;/span&gt;&lt;span class="p"&gt;;&lt;/span&gt;
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;The check reverses the process. It walks the rows in order, confirms each sequence number, confirms each &lt;code&gt;prevHash&lt;/code&gt;, recomputes each &lt;code&gt;rowHash&lt;/code&gt;, and reports the first row that does not match:&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight typescript"&gt;&lt;code&gt;&lt;span class="c1"&gt;// apps/web/convex/provenance.ts — verifyChain&lt;/span&gt;
&lt;span class="kd"&gt;let&lt;/span&gt; &lt;span class="nx"&gt;prev&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="nx"&gt;GENESIS&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="kd"&gt;let&lt;/span&gt; &lt;span class="nx"&gt;i&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="mi"&gt;0&lt;/span&gt;&lt;span class="p"&gt;;&lt;/span&gt; &lt;span class="nx"&gt;i&lt;/span&gt; &lt;span class="o"&gt;&amp;lt;&lt;/span&gt; &lt;span class="nx"&gt;rows&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nx"&gt;length&lt;/span&gt;&lt;span class="p"&gt;;&lt;/span&gt; &lt;span class="nx"&gt;i&lt;/span&gt;&lt;span class="o"&gt;++&lt;/span&gt;&lt;span class="p"&gt;)&lt;/span&gt; &lt;span class="p"&gt;{&lt;/span&gt;
  &lt;span class="kd"&gt;const&lt;/span&gt; &lt;span class="nx"&gt;row&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="nx"&gt;rows&lt;/span&gt;&lt;span class="p"&gt;[&lt;/span&gt;&lt;span class="nx"&gt;i&lt;/span&gt;&lt;span class="p"&gt;];&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;row&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nx"&gt;seq&lt;/span&gt; &lt;span class="o"&gt;!==&lt;/span&gt; &lt;span class="nx"&gt;i&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="p"&gt;{&lt;/span&gt;&lt;span class="na"&gt;ok&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="kc"&gt;false&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="na"&gt;brokenAtSeq&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="nx"&gt;row&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nx"&gt;seq&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="na"&gt;reason&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="s2"&gt;`seq_gap (expected &lt;/span&gt;&lt;span class="p"&gt;${&lt;/span&gt;&lt;span class="nx"&gt;i&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="k"&gt;if &lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="nx"&gt;row&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nx"&gt;prevHash&lt;/span&gt; &lt;span class="o"&gt;!==&lt;/span&gt; &lt;span class="nx"&gt;prev&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="p"&gt;{&lt;/span&gt;&lt;span class="na"&gt;ok&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="kc"&gt;false&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="na"&gt;brokenAtSeq&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="nx"&gt;row&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nx"&gt;seq&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="na"&gt;reason&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="dl"&gt;'&lt;/span&gt;&lt;span class="s1"&gt;prev_hash_mismatch&lt;/span&gt;&lt;span class="dl"&gt;'&lt;/span&gt;&lt;span class="p"&gt;};&lt;/span&gt;
  &lt;span class="p"&gt;}&lt;/span&gt;
  &lt;span class="c1"&gt;// ... recompute the row hash from its stored fields ...&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;recomputed&lt;/span&gt; &lt;span class="o"&gt;!==&lt;/span&gt; &lt;span class="nx"&gt;row&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nx"&gt;rowHash&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="p"&gt;{&lt;/span&gt;&lt;span class="na"&gt;ok&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="kc"&gt;false&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="na"&gt;brokenAtSeq&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="nx"&gt;row&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nx"&gt;seq&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="na"&gt;reason&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="dl"&gt;'&lt;/span&gt;&lt;span class="s1"&gt;row_hash_mismatch&lt;/span&gt;&lt;span class="dl"&gt;'&lt;/span&gt;&lt;span class="p"&gt;};&lt;/span&gt;
  &lt;span class="p"&gt;}&lt;/span&gt;
  &lt;span class="nx"&gt;prev&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="nx"&gt;row&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nx"&gt;rowHash&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="p"&gt;{&lt;/span&gt;&lt;span class="na"&gt;ok&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="kc"&gt;true&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="na"&gt;length&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="nx"&gt;rows&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nx"&gt;length&lt;/span&gt;&lt;span class="p"&gt;};&lt;/span&gt;
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;The &lt;strong&gt;Verify integrity&lt;/strong&gt; button on the demo page calls this function. A clean chain returns the row count. A changed row returns the sequence number where the break starts.&lt;/p&gt;

&lt;h3&gt;
  
  
  The trust boundary
&lt;/h3&gt;

&lt;p&gt;There is one more rule that makes the record trustworthy. The agents cannot reach the database directly. If an agent could write to Convex, it could write its own provenance row and forge the decision. So the agents run in a separate package that has no access to Convex at all. The agents reach the app only over HTTP, with a bearer token.&lt;/p&gt;

&lt;p&gt;The client the agents use makes this explicit. The token and the delegation are required inputs, with no default:&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight typescript"&gt;&lt;code&gt;&lt;span class="c1"&gt;// packages/api-client/src/index.ts&lt;/span&gt;
&lt;span class="k"&gt;export&lt;/span&gt; &lt;span class="kr"&gt;interface&lt;/span&gt; &lt;span class="nx"&gt;LockerClientOptions&lt;/span&gt; &lt;span class="p"&gt;{&lt;/span&gt;
  &lt;span class="cm"&gt;/** Bearer token for the agent's Kinde M2M identity. Required — no default. */&lt;/span&gt;
  &lt;span class="nl"&gt;agentToken&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="kr"&gt;string&lt;/span&gt;&lt;span class="p"&gt;;&lt;/span&gt;
  &lt;span class="cm"&gt;/** HMAC-signed delegation grant scoping what the agent may do. Required. */&lt;/span&gt;
  &lt;span class="nl"&gt;delegation&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="kr"&gt;string&lt;/span&gt;&lt;span class="p"&gt;;&lt;/span&gt;
  &lt;span class="cm"&gt;/** Base URL of the app's HTTP API. Optional; falls back to same-origin. */&lt;/span&gt;
  &lt;span class="nl"&gt;baseUrl&lt;/span&gt;&lt;span class="p"&gt;?:&lt;/span&gt; &lt;span class="kr"&gt;string&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 agent also never states its own authority. It asks the app to perform an action. The app derives the authority from the verified token, not from the request body:&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight typescript"&gt;&lt;code&gt;&lt;span class="c1"&gt;// packages/api-client/src/index.ts&lt;/span&gt;
&lt;span class="c1"&gt;// The agent NEVER states its authorization here — in enforced mode the app&lt;/span&gt;
&lt;span class="c1"&gt;// derives authority from the verified token/delegation, not this body.&lt;/span&gt;
&lt;span class="k"&gt;export&lt;/span&gt; &lt;span class="kr"&gt;interface&lt;/span&gt; &lt;span class="nx"&gt;ActionRequest&lt;/span&gt; &lt;span class="p"&gt;{&lt;/span&gt;
  &lt;span class="nl"&gt;orgCode&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="kr"&gt;string&lt;/span&gt;&lt;span class="p"&gt;;&lt;/span&gt;
  &lt;span class="nl"&gt;actorAgentId&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="kr"&gt;string&lt;/span&gt;&lt;span class="p"&gt;;&lt;/span&gt;
  &lt;span class="nl"&gt;action&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="nx"&gt;RecordAction&lt;/span&gt;&lt;span class="p"&gt;;&lt;/span&gt;
  &lt;span class="nl"&gt;correlationId&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="kr"&gt;string&lt;/span&gt;&lt;span class="p"&gt;;&lt;/span&gt;
  &lt;span class="nl"&gt;recordId&lt;/span&gt;&lt;span class="p"&gt;?:&lt;/span&gt; &lt;span class="kr"&gt;string&lt;/span&gt;&lt;span class="p"&gt;;&lt;/span&gt;
  &lt;span class="nl"&gt;title&lt;/span&gt;&lt;span class="p"&gt;?:&lt;/span&gt; &lt;span class="kr"&gt;string&lt;/span&gt;&lt;span class="p"&gt;;&lt;/span&gt;
  &lt;span class="nl"&gt;kind&lt;/span&gt;&lt;span class="p"&gt;?:&lt;/span&gt; &lt;span class="kr"&gt;string&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 rule that lives only in a comment is not a rule. So the build enforces the boundary in continuous integration. A script fails the build if any file in the agents package imports Convex or the app:&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;// scripts/check-boundaries.mjs&lt;/span&gt;
&lt;span class="kd"&gt;const&lt;/span&gt; &lt;span class="nx"&gt;FORBIDDEN&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="p"&gt;[&lt;/span&gt;
  &lt;span class="p"&gt;{&lt;/span&gt;&lt;span class="na"&gt;label&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="dl"&gt;'&lt;/span&gt;&lt;span class="s1"&gt;convex&lt;/span&gt;&lt;span class="dl"&gt;'&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="na"&gt;test&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="nx"&gt;s&lt;/span&gt;&lt;span class="p"&gt;)&lt;/span&gt; &lt;span class="o"&gt;=&amp;gt;&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="se"&gt;\/)&lt;/span&gt;&lt;span class="sr"&gt;convex&lt;/span&gt;&lt;span class="se"&gt;(\/&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="nf"&gt;test&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="nx"&gt;s&lt;/span&gt;&lt;span class="p"&gt;)},&lt;/span&gt;
  &lt;span class="p"&gt;{&lt;/span&gt;&lt;span class="na"&gt;label&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="dl"&gt;'&lt;/span&gt;&lt;span class="s1"&gt;apps/web&lt;/span&gt;&lt;span class="dl"&gt;'&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="na"&gt;test&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="nx"&gt;s&lt;/span&gt;&lt;span class="p"&gt;)&lt;/span&gt; &lt;span class="o"&gt;=&amp;gt;&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="se"&gt;\/)&lt;/span&gt;&lt;span class="sr"&gt;apps&lt;/span&gt;&lt;span class="se"&gt;\/&lt;/span&gt;&lt;span class="sr"&gt;web&lt;/span&gt;&lt;span class="se"&gt;(\/&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="nf"&gt;test&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="nx"&gt;s&lt;/span&gt;&lt;span class="p"&gt;)},&lt;/span&gt;
  &lt;span class="p"&gt;{&lt;/span&gt;&lt;span class="na"&gt;label&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="dl"&gt;'&lt;/span&gt;&lt;span class="s1"&gt;_generated&lt;/span&gt;&lt;span class="dl"&gt;'&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="na"&gt;test&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="nx"&gt;s&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;s&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nf"&gt;includes&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="dl"&gt;'&lt;/span&gt;&lt;span class="s1"&gt;_generated&lt;/span&gt;&lt;span class="dl"&gt;'&lt;/span&gt;&lt;span class="p"&gt;)}&lt;/span&gt;
&lt;span class="p"&gt;];&lt;/span&gt;
&lt;span class="c1"&gt;// ... scan every source file under packages/agents for these imports ...&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;violations&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nx"&gt;length&lt;/span&gt; &lt;span class="o"&gt;&amp;gt;&lt;/span&gt; &lt;span class="mi"&gt;0&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;error&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="dl"&gt;'&lt;/span&gt;&lt;span class="s1"&gt;✖ Boundary check FAILED — agents may not import the app or Convex.&lt;/span&gt;&lt;span class="dl"&gt;'&lt;/span&gt;&lt;span class="p"&gt;);&lt;/span&gt;
  &lt;span class="nx"&gt;process&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nf"&gt;exit&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="mi"&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;The CI workflow runs this check on every pull request, next to the type check, the lint, and the tests:&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;# .github/workflows/ci.yml&lt;/span&gt;
&lt;span class="pi"&gt;-&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;Boundary check (packages/agents must not import convex/apps/web/_generated)&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;npm run boundary-check&lt;/span&gt;
&lt;span class="pi"&gt;-&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;Test&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;npm test&lt;/span&gt;
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;The boundary is a gate, not a convention. The build fails if an agent ever reaches past the HTTP client.&lt;/p&gt;

&lt;h3&gt;
  
  
  Broken and enforced, side by side
&lt;/h3&gt;

&lt;p&gt;The same run, in the two modes, on the same two files, produces two very different records. The review agent targets &lt;em&gt;Halvorsen v. Meridian Logistics&lt;/em&gt;. The disposition agent targets &lt;em&gt;Project Cormorant — internal strategy memo&lt;/em&gt;.&lt;/p&gt;

&lt;p&gt;Broken mode, the blind log:&lt;/p&gt;

&lt;div class="table-wrapper-paragraph"&gt;&lt;table&gt;
&lt;thead&gt;
&lt;tr&gt;
&lt;th&gt;agent&lt;/th&gt;
&lt;th&gt;action&lt;/th&gt;
&lt;th&gt;file&lt;/th&gt;
&lt;th&gt;authority recorded?&lt;/th&gt;
&lt;/tr&gt;
&lt;/thead&gt;
&lt;tbody&gt;
&lt;tr&gt;
&lt;td&gt;review&lt;/td&gt;
&lt;td&gt;records:delete&lt;/td&gt;
&lt;td&gt;Halvorsen v. Meridian Logistics&lt;/td&gt;
&lt;td&gt;none&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;disposition&lt;/td&gt;
&lt;td&gt;records:delete&lt;/td&gt;
&lt;td&gt;Project Cormorant memo&lt;/td&gt;
&lt;td&gt;none&lt;/td&gt;
&lt;/tr&gt;
&lt;/tbody&gt;
&lt;/table&gt;&lt;/div&gt;

&lt;p&gt;The two rows look the same. The log cannot tell the illegal delete from the lawful one.&lt;/p&gt;

&lt;p&gt;Enforced mode, the authority record:&lt;/p&gt;

&lt;div class="table-wrapper-paragraph"&gt;&lt;table&gt;
&lt;thead&gt;
&lt;tr&gt;
&lt;th&gt;agent&lt;/th&gt;
&lt;th&gt;action&lt;/th&gt;
&lt;th&gt;file&lt;/th&gt;
&lt;th&gt;decision&lt;/th&gt;
&lt;th&gt;reason&lt;/th&gt;
&lt;th&gt;performed?&lt;/th&gt;
&lt;/tr&gt;
&lt;/thead&gt;
&lt;tbody&gt;
&lt;tr&gt;
&lt;td&gt;review&lt;/td&gt;
&lt;td&gt;records:delete&lt;/td&gt;
&lt;td&gt;Halvorsen v. Meridian Logistics&lt;/td&gt;
&lt;td&gt;deny&lt;/td&gt;
&lt;td&gt;insufficient_scope&lt;/td&gt;
&lt;td&gt;no&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;disposition&lt;/td&gt;
&lt;td&gt;records:delete&lt;/td&gt;
&lt;td&gt;Project Cormorant memo&lt;/td&gt;
&lt;td&gt;allow&lt;/td&gt;
&lt;td&gt;(none)&lt;/td&gt;
&lt;td&gt;yes&lt;/td&gt;
&lt;/tr&gt;
&lt;/tbody&gt;
&lt;/table&gt;&lt;/div&gt;

&lt;p&gt;Now the rows are different. The review agent's attempt is a recorded &lt;em&gt;deny&lt;/em&gt;, and &lt;em&gt;Halvorsen v. Meridian Logistics&lt;/em&gt; is still there. The disposition agent's delete is a recorded &lt;em&gt;allow&lt;/em&gt;. The auditor can read the authority from the record.&lt;/p&gt;

&lt;p&gt;The two screenshots above are the contrast, on the same two files. In broken mode the blind log fills and the authority panel stays empty. In enforced mode the authority panel fills — one DENY, one ALLOW — while the denied file survives.&lt;/p&gt;

&lt;h2&gt;
  
  
  Three things to take away
&lt;/h2&gt;

&lt;ul&gt;
&lt;li&gt;
&lt;strong&gt;Record the decision, not the action.&lt;/strong&gt; A log of actions cannot answer an auditor. A record of decisions, each with a verified identity and the exact permission, can.&lt;/li&gt;
&lt;li&gt;
&lt;strong&gt;Make the authority impossible to forge.&lt;/strong&gt; The agent must not verify itself, and it must not write its own record. A separate component makes the decision, and a separate store holds the row behind a trust boundary that the build enforces.&lt;/li&gt;
&lt;li&gt;
&lt;strong&gt;Make the record tamper-evident, and state its limits.&lt;/strong&gt; A hash chain catches a changed row. Name the stronger steps you did not take, such as external anchoring, so the record is honest about what it proves.&lt;/li&gt;
&lt;/ul&gt;

&lt;h2&gt;
  
  
  Try it
&lt;/h2&gt;

&lt;p&gt;The demo runs locally, and the static page opens without an account.&lt;/p&gt;

&lt;ul&gt;
&lt;li&gt;
&lt;strong&gt;Live page:&lt;/strong&gt; &lt;a href="https://evidence-locker-demo.vercel.app" rel="noopener noreferrer"&gt;https://evidence-locker-demo.vercel.app&lt;/a&gt;
&lt;/li&gt;
&lt;li&gt;
&lt;strong&gt;Source:&lt;/strong&gt; &lt;a href="https://github.com/kinde-starter-kits/evidence-locker-demo" rel="noopener noreferrer"&gt;https://github.com/kinde-starter-kits/evidence-locker-demo&lt;/a&gt;
&lt;/li&gt;
&lt;li&gt;
&lt;strong&gt;Authorization component:&lt;/strong&gt; kinde-convex-agent-auth&lt;/li&gt;
&lt;/ul&gt;

&lt;p&gt;The deployed page shows the scenario, the toggle, the mode banner, existing rows, and the &lt;strong&gt;Verify integrity&lt;/strong&gt; check. It does not run the agents, because the agent run is a server-side worker that does not fit a serverless function. To watch a full run, use the source:&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight shell"&gt;&lt;code&gt;npm ci
&lt;span class="nb"&gt;cd &lt;/span&gt;apps/web &lt;span class="o"&gt;&amp;amp;&amp;amp;&lt;/span&gt; npx convex dev            &lt;span class="c"&gt;# keep running; seeds the deployment URL&lt;/span&gt;
npx convex run seed:seedLocker &lt;span class="s1"&gt;'{"orgCode":"orgA"}'&lt;/span&gt;
npm run dev                              &lt;span class="c"&gt;# http://localhost:3000&lt;/span&gt;
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;Flip the toggle to Broken, run the agents, and read the blind log. Then flip to Enforced, run again, and read the authority record. Click &lt;strong&gt;Verify integrity&lt;/strong&gt; and watch the chain check itself.&lt;/p&gt;

&lt;p&gt;To prove the whole story headless, in both modes, with no browser and no live Kinde, run the end-to-end script:&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight shell"&gt;&lt;code&gt;npm run e2e
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;It seeds one org, runs the agents in broken mode and then enforced mode, and asserts each step: the blind log cannot tell the two deletes apart, the review delete is denied and the file survives, the disposition delete is allowed, and the hash chain verifies. That is the difference between a log that records what happened and a record an auditor would accept.&lt;/p&gt;

</description>
      <category>ai</category>
      <category>kinde</category>
      <category>agents</category>
      <category>security</category>
    </item>
    <item>
      <title>How to Build Agent-to-Agent Delegation That Attenuates Authority</title>
      <dc:creator>Shola Jegede</dc:creator>
      <pubDate>Fri, 07 Aug 2026 13:43:57 +0000</pubDate>
      <link>https://dev.to/sholajegede/how-to-build-agent-to-agent-delegation-that-attenuates-authority-instead-of-escalating-it-3798</link>
      <guid>https://dev.to/sholajegede/how-to-build-agent-to-agent-delegation-that-attenuates-authority-instead-of-escalating-it-3798</guid>
      <description>&lt;p&gt;A buyer at a company can approve a purchase up to $50,000. That is their limit. Anything larger needs a director.&lt;/p&gt;

&lt;p&gt;One morning the buyer starts a routine task. They ask a set of AI agents to source a supplier for a cold-chain shipment, negotiate a price, and place the order. The agents do the work. They find suppliers, run three rounds of negotiation, and pick a winner. Then they place the order.&lt;/p&gt;

&lt;p&gt;The order is for $142,000.&lt;/p&gt;

&lt;p&gt;No director saw it. No second approval happened. The buyer who can sign off on $50,000 just had $142,000 committed under their name, and every individual step looked correct while it happened.&lt;/p&gt;

&lt;p&gt;This is not a bug in any one agent. It is a failure in how authority moves between them. This article shows how that failure happens, and how a pattern called attenuation stops it.&lt;/p&gt;

&lt;p&gt;&lt;a href="https://media2.dev.to/dynamic/image/width=800%2Cheight=%2Cfit=scale-down%2Cgravity=auto%2Cformat=auto/https%3A%2F%2Fdev-to-uploads.s3.us-east-2.amazonaws.com%2Fuploads%2Farticles%2Fdcu1wxpdrgzkrk6s9rev.png" class="article-body-image-wrapper"&gt;&lt;img src="https://media2.dev.to/dynamic/image/width=800%2Cheight=%2Cfit=scale-down%2Cgravity=auto%2Cformat=auto/https%3A%2F%2Fdev-to-uploads.s3.us-east-2.amazonaws.com%2Fuploads%2Farticles%2Fdcu1wxpdrgzkrk6s9rev.png" alt="Denied run: the delegation chain stops at hop three, attenuated mode" width="800" height="1035"&gt;&lt;/a&gt;&lt;/p&gt;

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

&lt;p&gt;The task runs across three agents. Each one does a part of the job, then hands the rest to the next.&lt;/p&gt;

&lt;ul&gt;
&lt;li&gt;The &lt;strong&gt;sourcing agent&lt;/strong&gt; reads the requisition and searches for suppliers that can do the work.&lt;/li&gt;
&lt;li&gt;The &lt;strong&gt;negotiation agent&lt;/strong&gt; asks those suppliers for quotes and runs rounds to bring the price down.&lt;/li&gt;
&lt;li&gt;The &lt;strong&gt;ordering agent&lt;/strong&gt; picks the winner and places the purchase order.&lt;/li&gt;
&lt;/ul&gt;

&lt;p&gt;This is a chain. The sourcing agent hands off to the negotiation agent. The negotiation agent hands off to the ordering agent. Work moves down the chain, one handoff at a time.&lt;/p&gt;

&lt;p&gt;Each handoff needs to carry authority. The ordering agent cannot place an order unless something gave it the right to do so. In a delegation model, that right comes from the person who started the run. The buyer delegates to the sourcing agent, which delegates onward, and so the authority to act traces back to the buyer.&lt;/p&gt;

&lt;p&gt;That is the design. The problem is in how most systems build the handoff.&lt;/p&gt;

&lt;h2&gt;
  
  
  The failure, in the seams
&lt;/h2&gt;

&lt;p&gt;Look at what each agent is allowed to do. In a common but broken design, each handoff grants the next agent the permissions its task needs. The sourcing agent needs to read and to request quotes, so it gets those. The negotiation agent needs to negotiate, so it gets that. The ordering agent needs to place an order, so it gets that.&lt;/p&gt;

&lt;p&gt;Read that last line again. "It needs to place an order, so it gets that."&lt;/p&gt;

&lt;p&gt;The ordering agent needs to place a $142,000 order. So the handoff grants it the scope to place a $142,000 order. Nobody checked whether the buyer at the top of the chain could place a $142,000 order. The buyer cannot. But the grant was sized to the task, not to the person who started it.&lt;/p&gt;

&lt;p&gt;Here is the authority at each hop in the broken design. This is real output from the demo, running a Requester who holds no ordering authority at all:&lt;/p&gt;

&lt;div class="table-wrapper-paragraph"&gt;&lt;table&gt;
&lt;thead&gt;
&lt;tr&gt;
&lt;th&gt;Hop&lt;/th&gt;
&lt;th&gt;Agent&lt;/th&gt;
&lt;th&gt;Scopes it holds&lt;/th&gt;
&lt;/tr&gt;
&lt;/thead&gt;
&lt;tbody&gt;
&lt;tr&gt;
&lt;td&gt;Start&lt;/td&gt;
&lt;td&gt;Requester (human)&lt;/td&gt;
&lt;td&gt;
&lt;code&gt;procurement:read&lt;/code&gt;, &lt;code&gt;quotes:request&lt;/code&gt;
&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;1&lt;/td&gt;
&lt;td&gt;Sourcing&lt;/td&gt;
&lt;td&gt;
&lt;code&gt;procurement:read&lt;/code&gt;, &lt;code&gt;quotes:request&lt;/code&gt;
&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;2&lt;/td&gt;
&lt;td&gt;Negotiation&lt;/td&gt;
&lt;td&gt;
&lt;code&gt;procurement:read&lt;/code&gt;, &lt;code&gt;quotes:request&lt;/code&gt;, &lt;code&gt;quotes:negotiate&lt;/code&gt;
&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;3&lt;/td&gt;
&lt;td&gt;Ordering&lt;/td&gt;
&lt;td&gt;
&lt;code&gt;procurement:read&lt;/code&gt;, &lt;code&gt;quotes:request&lt;/code&gt;, &lt;code&gt;quotes:negotiate&lt;/code&gt;, &lt;code&gt;orders:place:t2&lt;/code&gt;
&lt;/td&gt;
&lt;/tr&gt;
&lt;/tbody&gt;
&lt;/table&gt;&lt;/div&gt;

&lt;p&gt;The chain grew. The requester started with no power to place orders. By hop three, the chain holds &lt;code&gt;orders:place:t2&lt;/code&gt;, the scope to place an order up to $250,000, and that authority appeared from nowhere. No single step looks wrong. The sourcing agent did sourcing. The negotiation agent negotiated. The ordering agent placed an order it was told it could place. The leak is not inside any agent. The leak is in the seams between them.&lt;/p&gt;

&lt;p&gt;&lt;a href="https://media2.dev.to/dynamic/image/width=800%2Cheight=%2Cfit=scale-down%2Cgravity=auto%2Cformat=auto/https%3A%2F%2Fdev-to-uploads.s3.us-east-2.amazonaws.com%2Fuploads%2Farticles%2Fd90obbxwep1ttmu3m25i.png" class="article-body-image-wrapper"&gt;&lt;img src="https://media2.dev.to/dynamic/image/width=800%2Cheight=%2Cfit=scale-down%2Cgravity=auto%2Cformat=auto/https%3A%2F%2Fdev-to-uploads.s3.us-east-2.amazonaws.com%2Fuploads%2Farticles%2Fd90obbxwep1ttmu3m25i.png" alt="A left-to-right flow: Requester → Sourcing → Negotiation → Ordering. The scope set&lt;br&gt;
  widens at each hop, and orders:place:t2 appears at the red Ordering hop with no&lt;br&gt;
  source above it. Shows authority growing to fit the task." width="797" height="112"&gt;&lt;/a&gt;&lt;/p&gt;

&lt;p&gt;&lt;em&gt;Broken delegation. Each hop is granted the scopes its task needs, so the set grows down the chain. &lt;code&gt;orders:place:t2&lt;/code&gt; enters at hop three with no source above it.&lt;/em&gt;&lt;/p&gt;

&lt;p&gt;This is worse than a single agent holding too much power. A single over-privileged agent is easy to see. You look at it, you see it has more than it should, you cut it back. A delegation chain hides the problem across several handoffs. Each grant looks reasonable on its own. You only see the escalation when you add up the whole chain and compare the end against the start.&lt;/p&gt;
&lt;h2&gt;
  
  
  Three fixes that do not work
&lt;/h2&gt;

&lt;p&gt;The failure is clear, so the fixes seem clear too. Each of these is a real attempt that teams make. Each one fails.&lt;/p&gt;

&lt;p&gt;&lt;strong&gt;Fix one: check the scope only at the final step.&lt;/strong&gt; Let the chain carry whatever it needs, but before the ordering agent places the order, check that it holds an order scope. The check passes. The ordering agent does hold the scope, because the broken handoff gave it exactly that. The check confirms the escalation instead of stopping it. A check at the end cannot catch authority that was widened along the way, because by the end the wide authority looks like the correct authority.&lt;/p&gt;

&lt;p&gt;&lt;strong&gt;Fix two: give each agent a fixed role.&lt;/strong&gt; Set the ordering agent's role to "can place orders up to $250,000" and leave it there. Now the ordering agent has a stable, known permission. But it has that permission on &lt;em&gt;every&lt;/em&gt; run, no matter who started it. A buyer starts a run, and the ordering agent still places the large order, because the agent's fixed role never looked at the buyer. A fixed role per agent ignores the person the work is for. The whole point of delegation is that the agent acts for someone. A fixed role forgets who that someone is.&lt;/p&gt;

&lt;p&gt;&lt;strong&gt;Fix three: trust the caller's claimed authority.&lt;/strong&gt; Let each agent tell the next agent what authority to use. The negotiation agent says to the ordering agent, "act with authority to place this order." This is the worst option. It lets any step in the chain grant power it was never given. The caller decides its own permissions, which means there are no permissions at all. Anything that can ask for more, gets more.&lt;/p&gt;

&lt;p&gt;Each fix fails for the same underlying reason. Each one treats a hop as its own decision. But a delegation chain is not a set of separate decisions. It is one decision, made once at the top, that every later hop must stay inside.&lt;/p&gt;
&lt;h2&gt;
  
  
  The principle
&lt;/h2&gt;

&lt;p&gt;The rule that fixes this is short.&lt;/p&gt;

&lt;p&gt;&lt;strong&gt;Authority must only ever shrink as it moves down a chain.&lt;/strong&gt;&lt;/p&gt;

&lt;p&gt;The buyer starts with a $50,000 ceiling. Every hop after that can hold that ceiling or less. Never more. A hop can drop authority, narrow it, hand on a subset. A hop can never add authority the chain did not already have.&lt;/p&gt;

&lt;p&gt;This is called attenuation. To attenuate is to make weaker. Each handoff makes the authority weaker, or leaves it the same. The chain is append-only in one direction: you can keep adding restrictions, and you can never take a restriction away.&lt;/p&gt;

&lt;p&gt;Put the rule against the broken chain and the escalation is impossible. The buyer holds a $50,000 ceiling. The sourcing agent holds a subset of that. The negotiation agent holds a subset of that. The ordering agent holds a subset of that. When the ordering agent tries to place a $142,000 order, the authority it holds traces straight back to the buyer's $50,000 ceiling. The order is refused. No director approved it, so it does not happen.&lt;/p&gt;

&lt;p&gt;That is the fix in one sentence. The rest is making it real: express a dollar ceiling as something a computer can intersect, issue each handoff so it can only narrow, and root the whole chain in a real person.&lt;/p&gt;
&lt;h2&gt;
  
  
  Attenuation as set math
&lt;/h2&gt;

&lt;p&gt;Authority in this system is a set of scopes. A scope is one named permission. The buyer holds a set like this:&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="err"&gt;procurement:read&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt;&lt;span class="w"&gt; &lt;/span&gt;&lt;span class="err"&gt;quotes:request&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt;&lt;span class="w"&gt; &lt;/span&gt;&lt;span class="err"&gt;quotes:negotiate&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt;&lt;span class="w"&gt; &lt;/span&gt;&lt;span class="err"&gt;orders:place:t&lt;/span&gt;&lt;span class="mi"&gt;1&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;Each hop in the chain also holds a set. The rule "authority only shrinks" becomes one operation on those sets: intersection.&lt;/p&gt;

&lt;p&gt;When a hop hands off to the next hop, the next hop receives the intersection of the person's scopes and the next agent's own scopes. Intersection keeps only what appears in both sets. It can never add a scope the person did not have. That is the whole guarantee, in one word. Intersection makes escalation impossible, because you cannot intersect your way to a larger set.&lt;/p&gt;

&lt;p&gt;Root the chain in the human, and the math holds all the way down. The buyer's set is the top. Every hop below is an intersection with the buyer's set. The result can stay the same or get smaller at each step. It can never grow.&lt;/p&gt;

&lt;h2&gt;
  
  
  Turning a dollar ceiling into a scope
&lt;/h2&gt;

&lt;p&gt;The buyer's real limit is a dollar figure: $50,000. Sets do not intersect dollar figures. So the design turns the ceiling into a scope.&lt;/p&gt;

&lt;p&gt;There are three order tiers:&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight plaintext"&gt;&lt;code&gt;orders:place:t1   up to $50,000
orders:place:t2   up to $250,000
orders:place:t3   no ceiling
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;A ceiling is now a scope in the set, not a number in a separate field. This matters, because it means intersection handles the ceiling for free. No extra code compares amounts across a chain. The same set intersection that controls read and negotiate permissions also controls how much a chain can spend.&lt;/p&gt;

&lt;p&gt;The three roles map to tiers like this:&lt;/p&gt;

&lt;div class="table-wrapper-paragraph"&gt;&lt;table&gt;
&lt;thead&gt;
&lt;tr&gt;
&lt;th&gt;Role&lt;/th&gt;
&lt;th&gt;Scopes&lt;/th&gt;
&lt;/tr&gt;
&lt;/thead&gt;
&lt;tbody&gt;
&lt;tr&gt;
&lt;td&gt;Requester&lt;/td&gt;
&lt;td&gt;
&lt;code&gt;procurement:read&lt;/code&gt;, &lt;code&gt;quotes:request&lt;/code&gt;
&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;Buyer&lt;/td&gt;
&lt;td&gt;the above, plus &lt;code&gt;quotes:negotiate&lt;/code&gt;, &lt;code&gt;orders:place:t1&lt;/code&gt;
&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;Director&lt;/td&gt;
&lt;td&gt;the above, plus &lt;code&gt;orders:place:t2&lt;/code&gt;, &lt;code&gt;orders:place:t3&lt;/code&gt;
&lt;/td&gt;
&lt;/tr&gt;
&lt;/tbody&gt;
&lt;/table&gt;&lt;/div&gt;

&lt;p&gt;A buyer holds &lt;code&gt;orders:place:t1&lt;/code&gt; and nothing higher. A buyer can approve up to $50,000. A director holds all three tiers and can approve any amount. The dollar ceiling is now something a set operation can enforce.&lt;/p&gt;

&lt;p&gt;These six scopes are real permissions in &lt;a href="https://kinde.com/?utm_source=devto&amp;amp;utm_medium=content&amp;amp;utm_campaign=shola&amp;amp;campaignid=chatgptapp&amp;amp;network=&amp;amp;adgroup=&amp;amp;keyword=&amp;amp;matchtype=&amp;amp;creative=19&amp;amp;device=&amp;amp;adposition=" rel="noopener noreferrer"&gt;Kinde&lt;/a&gt;. The API defines them once, and each role holds a subset.&lt;/p&gt;

&lt;p&gt;&lt;a href="https://media2.dev.to/dynamic/image/width=800%2Cheight=%2Cfit=scale-down%2Cgravity=auto%2Cformat=auto/https%3A%2F%2Fdev-to-uploads.s3.us-east-2.amazonaws.com%2Fuploads%2Farticles%2Fjfgu766qh0iufe8ha55z.png" class="article-body-image-wrapper"&gt;&lt;img src="https://media2.dev.to/dynamic/image/width=800%2Cheight=%2Cfit=scale-down%2Cgravity=auto%2Cformat=auto/https%3A%2F%2Fdev-to-uploads.s3.us-east-2.amazonaws.com%2Fuploads%2Farticles%2Fjfgu766qh0iufe8ha55z.png" alt="The six permissions defined in Kinde" width="800" height="461"&gt;&lt;/a&gt;&lt;/p&gt;

&lt;h2&gt;
  
  
  The chain, attenuated
&lt;/h2&gt;

&lt;p&gt;Run the same task again, but this time each handoff intersects instead of grows. The buyer starts the run. The scopes narrow at each hop, and no hop can hold a tier the buyer never had. This is real output from the demo:&lt;/p&gt;

&lt;div class="table-wrapper-paragraph"&gt;&lt;table&gt;
&lt;thead&gt;
&lt;tr&gt;
&lt;th&gt;Hop&lt;/th&gt;
&lt;th&gt;Agent&lt;/th&gt;
&lt;th&gt;Scopes it holds&lt;/th&gt;
&lt;/tr&gt;
&lt;/thead&gt;
&lt;tbody&gt;
&lt;tr&gt;
&lt;td&gt;Start&lt;/td&gt;
&lt;td&gt;Buyer (human)&lt;/td&gt;
&lt;td&gt;
&lt;code&gt;procurement:read&lt;/code&gt;, &lt;code&gt;quotes:request&lt;/code&gt;, &lt;code&gt;quotes:negotiate&lt;/code&gt;, &lt;code&gt;orders:place:t1&lt;/code&gt;
&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;1&lt;/td&gt;
&lt;td&gt;Sourcing&lt;/td&gt;
&lt;td&gt;
&lt;code&gt;procurement:read&lt;/code&gt;, &lt;code&gt;quotes:request&lt;/code&gt;
&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;2&lt;/td&gt;
&lt;td&gt;Negotiation&lt;/td&gt;
&lt;td&gt;
&lt;code&gt;procurement:read&lt;/code&gt;, &lt;code&gt;quotes:negotiate&lt;/code&gt;
&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;3&lt;/td&gt;
&lt;td&gt;Ordering&lt;/td&gt;
&lt;td&gt;
&lt;code&gt;procurement:read&lt;/code&gt;, &lt;code&gt;orders:place:t1&lt;/code&gt;
&lt;/td&gt;
&lt;/tr&gt;
&lt;/tbody&gt;
&lt;/table&gt;&lt;/div&gt;

&lt;p&gt;Look at hop three. The ordering agent holds &lt;code&gt;orders:place:t1&lt;/code&gt;, and only &lt;code&gt;t1&lt;/code&gt;, because that is the highest order tier the buyer had to give. The negotiation reached a winning quote of $142,000. That amount needs &lt;code&gt;orders:place:t2&lt;/code&gt;. The ordering agent does not hold &lt;code&gt;t2&lt;/code&gt;. The buyer never held &lt;code&gt;t2&lt;/code&gt;, so no hop below the buyer could receive it.&lt;/p&gt;

&lt;p&gt;The order is refused.&lt;/p&gt;

&lt;p&gt;&lt;a href="https://media2.dev.to/dynamic/image/width=800%2Cheight=%2Cfit=scale-down%2Cgravity=auto%2Cformat=auto/https%3A%2F%2Fdev-to-uploads.s3.us-east-2.amazonaws.com%2Fuploads%2Farticles%2F9ft3rttliwett0rcxmz2.png" class="article-body-image-wrapper"&gt;&lt;img src="https://media2.dev.to/dynamic/image/width=800%2Cheight=%2Cfit=scale-down%2Cgravity=auto%2Cformat=auto/https%3A%2F%2Fdev-to-uploads.s3.us-east-2.amazonaws.com%2Fuploads%2Farticles%2F9ft3rttliwett0rcxmz2.png" alt="The same left-to-right flow: Buyer → Sourcing → Negotiation → Ordering, all in&lt;br&gt;
  green except the last. Each hop holds a subset of the one before, so the set only&lt;br&gt;
  narrows. The red Ordering hop holds t1, the order needs t2, and it is denied." width="800" height="93"&gt;&lt;/a&gt;&lt;/p&gt;

&lt;p&gt;&lt;em&gt;Attenuated delegation. Each hop is the buyer's scopes intersected with that agent's scopes, so the set can only shrink. Hop three holds &lt;code&gt;t1&lt;/code&gt;, the order needs &lt;code&gt;t2&lt;/code&gt;, and the order is denied.&lt;/em&gt;&lt;/p&gt;
&lt;h2&gt;
  
  
  Who does what
&lt;/h2&gt;

&lt;p&gt;The intersection is split across two layers, and it helps to be exact about which layer does which job. Overclaiming here is easy, so here is the plain version.&lt;/p&gt;

&lt;p&gt;The application computes each hop's scopes. It reads the person's scopes, reads the next agent's own scopes, and intersects them. This is the step that roots the chain in the human. If the application does this wrong, the human ceiling is not enforced. This part is application logic, and it is where the "shrink" rule lives.&lt;/p&gt;

&lt;p&gt;The authorization component enforces two things under that. First, it refuses to issue a delegation that holds a scope the target agent does not have. That call fails with &lt;code&gt;scopes_exceed_agent&lt;/code&gt;. Second, at decision time it computes the effective scopes as the agent's own scopes intersected with the delegation's scopes, and it allows an action only if the result contains the action's scope. Neither step can widen authority.&lt;/p&gt;

&lt;p&gt;So the guarantee is a chain of two intersections. The application intersects the person with the agent to set the delegation. The component intersects the agent with that delegation to decide. Because the delegation already carries the person's ceiling, the decision cannot exceed it.&lt;/p&gt;

&lt;p&gt;&lt;a href="https://media2.dev.to/dynamic/image/width=800%2Cheight=%2Cfit=scale-down%2Cgravity=auto%2Cformat=auto/https%3A%2F%2Fdev-to-uploads.s3.us-east-2.amazonaws.com%2Fuploads%2Farticles%2Ftfk4qc5mrs3yi3mm31w3.png" class="article-body-image-wrapper"&gt;&lt;img src="https://media2.dev.to/dynamic/image/width=800%2Cheight=%2Cfit=scale-down%2Cgravity=auto%2Cformat=auto/https%3A%2F%2Fdev-to-uploads.s3.us-east-2.amazonaws.com%2Fuploads%2Farticles%2Ftfk4qc5mrs3yi3mm31w3.png" alt="Three stacked boxes: Kinde, Application, Authorization component. Kinde issues and&lt;br&gt;
  verifies each agent's identity and scopes. The application intersects the&lt;br&gt;
  person's scopes with the agent's to set the delegation. The component refuses any&lt;br&gt;
  scope the agent lacks and decides on agent ∩ delegation. Shows where identity ends&lt;br&gt;
  and the chain begins." width="740" height="2220"&gt;&lt;/a&gt;&lt;/p&gt;

&lt;p&gt;&lt;em&gt;Where identity ends and the chain begins. Kinde proves who each agent is and what scopes it holds. The application roots each hop in the person and intersects. The component refuses to over-issue and decides on the intersection.&lt;/em&gt;&lt;/p&gt;

&lt;p&gt;&lt;a href="https://kinde.com/?utm_source=devto&amp;amp;utm_medium=content&amp;amp;utm_campaign=shola&amp;amp;campaignid=chatgptapp&amp;amp;network=&amp;amp;adgroup=&amp;amp;keyword=&amp;amp;matchtype=&amp;amp;creative=19&amp;amp;device=&amp;amp;adposition=" rel="noopener noreferrer"&gt;Kinde&lt;/a&gt; issues and verifies the identity and scopes at each hop. Every agent has its own machine identity, and every token is checked against Kinde before the system trusts it. The delegation chain, the issuing of one hop from the person's scopes and the intersection that narrows it, sits on top of that verified identity. Read this plainly: Kinde proves identity and scopes; the attenuation chain is the job of the authorization component and the application above it. Kinde does not ship chain attenuation on its own.&lt;/p&gt;

&lt;p&gt;Each agent is a &lt;a href="https://kinde.com/?utm_source=devto&amp;amp;utm_medium=content&amp;amp;utm_campaign=shola&amp;amp;campaignid=chatgptapp&amp;amp;network=&amp;amp;adgroup=&amp;amp;keyword=&amp;amp;matchtype=&amp;amp;creative=19&amp;amp;device=&amp;amp;adposition=" rel="noopener noreferrer"&gt;separate machine-to-machine application in Kinde&lt;/a&gt;. The sourcing, negotiation, and ordering agents each hold their own credentials and mint their own token.&lt;/p&gt;

&lt;p&gt;&lt;a href="https://media2.dev.to/dynamic/image/width=800%2Cheight=%2Cfit=scale-down%2Cgravity=auto%2Cformat=auto/https%3A%2F%2Fdev-to-uploads.s3.us-east-2.amazonaws.com%2Fuploads%2Farticles%2Fm9wmxfli0zscg7gjacid.png" class="article-body-image-wrapper"&gt;&lt;img src="https://media2.dev.to/dynamic/image/width=800%2Cheight=%2Cfit=scale-down%2Cgravity=auto%2Cformat=auto/https%3A%2F%2Fdev-to-uploads.s3.us-east-2.amazonaws.com%2Fuploads%2Farticles%2Fm9wmxfli0zscg7gjacid.png" alt="The three agent identities, one machine-to-machine app each" width="800" height="461"&gt;&lt;/a&gt;&lt;/p&gt;
&lt;h2&gt;
  
  
  The denial, step by step
&lt;/h2&gt;

&lt;p&gt;Here is exactly what happens when the ordering agent tries to place the $142,000 order in the attenuated chain.&lt;/p&gt;

&lt;ol&gt;
&lt;li&gt;The ordering agent holds a delegation with the scopes &lt;code&gt;{ procurement:read, orders:place:t1 }&lt;/code&gt;. That is the buyer's ceiling intersected with the ordering agent's scopes.&lt;/li&gt;
&lt;li&gt;The order is $142,000. The server reads the amount and finds the tier it needs: &lt;code&gt;orders:place:t2&lt;/code&gt;.&lt;/li&gt;
&lt;li&gt;The server asks the component to authorize the action. The effective scopes are the intersection of the agent's own scopes and the delegation's scopes. The result does not contain &lt;code&gt;orders:place:t2&lt;/code&gt;.&lt;/li&gt;
&lt;li&gt;The component denies the action.&lt;/li&gt;
&lt;/ol&gt;

&lt;p&gt;The denial is machine-readable. It carries the reason, the missing scope, and a correlation id that ties it to this exact run:&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;"denied"&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt;&lt;span class="w"&gt; &lt;/span&gt;&lt;span class="kc"&gt;true&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt;&lt;span class="w"&gt;
  &lt;/span&gt;&lt;span class="nl"&gt;"reason"&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt;&lt;span class="w"&gt; &lt;/span&gt;&lt;span class="s2"&gt;"insufficient_scope"&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt;&lt;span class="w"&gt;
  &lt;/span&gt;&lt;span class="nl"&gt;"requiredScopes"&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="s2"&gt;"orders:place:t2"&lt;/span&gt;&lt;span class="p"&gt;],&lt;/span&gt;&lt;span class="w"&gt;
  &lt;/span&gt;&lt;span class="nl"&gt;"correlationId"&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt;&lt;span class="w"&gt; &lt;/span&gt;&lt;span class="s2"&gt;"b4a54d02-d79b-43f5-b181-85576b07d41a"&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;No order row is written. The run does not crash. It ends with a clean terminal event that says the run stopped because the authority was not there. A person reading the result sees a plain statement: this chain can approve up to $50,000, and this order is $142,000, so it was refused.&lt;/p&gt;

&lt;h2&gt;
  
  
  The record
&lt;/h2&gt;

&lt;p&gt;Every step in the chain is recorded, so the whole decision can be read back after the run.&lt;/p&gt;

&lt;p&gt;The record holds each delegation, its parent, the scopes it carried, and the hop it sat at. It holds the authorization decision, with the effective scopes that produced it and the action that was refused. Given a run, you can reconstruct the chain hop by hop: what the buyer held, what each agent received, where the scopes narrowed, and the exact line where the order was refused and why.&lt;/p&gt;

&lt;p&gt;The run timeline below is the real event stream from the denied Buyer run. It reads top to bottom as the story: sourcing searches and shortlists, negotiation runs its rounds and ranks a winner, and the ordering hop ends in a denial, not an order.&lt;/p&gt;

&lt;p&gt;&lt;a href="https://media2.dev.to/dynamic/image/width=800%2Cheight=%2Cfit=scale-down%2Cgravity=auto%2Cformat=auto/https%3A%2F%2Fdev-to-uploads.s3.us-east-2.amazonaws.com%2Fuploads%2Farticles%2F2s6s6mtpxa623s2679hu.png" class="article-body-image-wrapper"&gt;&lt;img src="https://media2.dev.to/dynamic/image/width=800%2Cheight=%2Cfit=scale-down%2Cgravity=auto%2Cformat=auto/https%3A%2F%2Fdev-to-uploads.s3.us-east-2.amazonaws.com%2Fuploads%2Farticles%2F2s6s6mtpxa623s2679hu.png" alt="The run timeline of a denied run, top to bottom" width="800" height="1183"&gt;&lt;/a&gt;&lt;/p&gt;

&lt;p&gt;This is what turns "trust us, it is secure" into "here is the chain, read it yourself." The guarantee is not a claim. It is a record you can audit.&lt;/p&gt;

&lt;h2&gt;
  
  
  Building it
&lt;/h2&gt;

&lt;p&gt;The demo is a working procurement application. A person signs in, writes a requisition, and starts a run. Three agents source, negotiate, and order. The delegation chain and the run timeline stream on screen as it happens. You can watch a run hold the line, or watch it leak, by changing one server setting.&lt;/p&gt;

&lt;p&gt;Here is the stack, and what each part does.&lt;/p&gt;

&lt;ul&gt;
&lt;li&gt;
&lt;strong&gt;Next.js&lt;/strong&gt; serves the app and holds the API routes the agents call.&lt;/li&gt;
&lt;li&gt;
&lt;strong&gt;Convex&lt;/strong&gt; is the database and the system of record. It holds the requisitions, quotes, orders, delegations, and the event stream, and it hosts the authorization component.&lt;/li&gt;
&lt;li&gt;
&lt;strong&gt;Kinde&lt;/strong&gt; issues and verifies every identity. The three human roles sign in through Kinde. The three agents each hold their own machine identity from Kinde.&lt;/li&gt;
&lt;li&gt;
&lt;strong&gt;langgraph.js&lt;/strong&gt; runs the three-agent graph: sourcing, then negotiation, then ordering.&lt;/li&gt;
&lt;li&gt;
&lt;strong&gt;Qdrant&lt;/strong&gt; matches a plain-language requisition against supplier capability text, so the sourcing agent finds suppliers by meaning, not by keyword.&lt;/li&gt;
&lt;li&gt;
&lt;strong&gt;Trigger.dev&lt;/strong&gt; runs the agent graph as a durable background task, so long negotiation rounds retry and survive.&lt;/li&gt;
&lt;/ul&gt;

&lt;p&gt;Every part earns its place. Qdrant does a job a database &lt;code&gt;WHERE&lt;/code&gt; clause cannot: a buyer writes "move temperature-sensitive vaccine shipments and keep them refrigerated," and the search returns a cold-chain logistics firm whose description shares no keywords with the request.&lt;/p&gt;

&lt;h2&gt;
  
  
  Two modes, decided by the server
&lt;/h2&gt;

&lt;p&gt;The demo ships with both behaviors in one codebase. A single setting, &lt;code&gt;AUTHZ_MODE&lt;/code&gt;, chooses which one runs.&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight conf"&gt;&lt;code&gt;&lt;span class="n"&gt;AUTHZ_MODE&lt;/span&gt;=&lt;span class="n"&gt;broken&lt;/span&gt;       &lt;span class="n"&gt;each&lt;/span&gt; &lt;span class="n"&gt;handoff&lt;/span&gt; &lt;span class="n"&gt;grants&lt;/span&gt; &lt;span class="n"&gt;the&lt;/span&gt; &lt;span class="n"&gt;scopes&lt;/span&gt; &lt;span class="n"&gt;the&lt;/span&gt; &lt;span class="n"&gt;next&lt;/span&gt; &lt;span class="n"&gt;task&lt;/span&gt; &lt;span class="n"&gt;needs&lt;/span&gt;
&lt;span class="n"&gt;AUTHZ_MODE&lt;/span&gt;=&lt;span class="n"&gt;attenuated&lt;/span&gt;   &lt;span class="n"&gt;each&lt;/span&gt; &lt;span class="n"&gt;handoff&lt;/span&gt; &lt;span class="n"&gt;intersects&lt;/span&gt; &lt;span class="n"&gt;with&lt;/span&gt; &lt;span class="n"&gt;the&lt;/span&gt; &lt;span class="n"&gt;person&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;scopes&lt;/span&gt;
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;The mode is read on the server, from deployment configuration. It is never read from a request. An agent cannot ask for a mode, and a client cannot set one. This matters for a security demo: if a request could pick its own authorization mode, the demo would prove nothing. The server decides, and the request lives with the decision.&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight typescript"&gt;&lt;code&gt;&lt;span class="c1"&gt;// convex/authz.ts&lt;/span&gt;
&lt;span class="k"&gt;export&lt;/span&gt; &lt;span class="kd"&gt;function&lt;/span&gt; &lt;span class="nf"&gt;authzMode&lt;/span&gt;&lt;span class="p"&gt;():&lt;/span&gt; &lt;span class="dl"&gt;"&lt;/span&gt;&lt;span class="s2"&gt;broken&lt;/span&gt;&lt;span class="dl"&gt;"&lt;/span&gt; &lt;span class="o"&gt;|&lt;/span&gt; &lt;span class="dl"&gt;"&lt;/span&gt;&lt;span class="s2"&gt;attenuated&lt;/span&gt;&lt;span class="dl"&gt;"&lt;/span&gt; &lt;span class="p"&gt;{&lt;/span&gt;
  &lt;span class="k"&gt;return&lt;/span&gt; &lt;span class="nx"&gt;process&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nx"&gt;env&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nx"&gt;AUTHZ_MODE&lt;/span&gt; &lt;span class="o"&gt;===&lt;/span&gt; &lt;span class="dl"&gt;"&lt;/span&gt;&lt;span class="s2"&gt;attenuated&lt;/span&gt;&lt;span class="dl"&gt;"&lt;/span&gt; &lt;span class="p"&gt;?&lt;/span&gt; &lt;span class="dl"&gt;"&lt;/span&gt;&lt;span class="s2"&gt;attenuated&lt;/span&gt;&lt;span class="dl"&gt;"&lt;/span&gt; &lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="dl"&gt;"&lt;/span&gt;&lt;span class="s2"&gt;broken&lt;/span&gt;&lt;span class="dl"&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;The demo deployment sets the value to &lt;code&gt;attenuated&lt;/code&gt;. Switch it to &lt;code&gt;broken&lt;/code&gt; on the deployment, run the same requisition, and watch the same order go through.&lt;/p&gt;

&lt;h2&gt;
  
  
  Minting a hop
&lt;/h2&gt;

&lt;p&gt;Each agent, before it works, mints its own hop of the delegation chain. The application computes the hop's scopes, then issues the delegation through the component. The component roots the chain in the human requester.&lt;/p&gt;

&lt;p&gt;In attenuated mode, the new hop's scopes are the intersection of the requester's scopes and that agent's own scopes.&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight typescript"&gt;&lt;code&gt;&lt;span class="c1"&gt;// convex/authz.ts: the scopes a hop receives, in attenuated mode&lt;/span&gt;
&lt;span class="k"&gt;export&lt;/span&gt; &lt;span class="kd"&gt;function&lt;/span&gt; &lt;span class="nf"&gt;attenuatedHopScopes&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="nx"&gt;requesterScopes&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="kr"&gt;string&lt;/span&gt;&lt;span class="p"&gt;[],&lt;/span&gt; &lt;span class="nx"&gt;step&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="nx"&gt;Step&lt;/span&gt;&lt;span class="p"&gt;)&lt;/span&gt; &lt;span class="p"&gt;{&lt;/span&gt;
  &lt;span class="c1"&gt;// keep only what the requester holds and the agent is registered for&lt;/span&gt;
  &lt;span class="k"&gt;return&lt;/span&gt; &lt;span class="nf"&gt;intersect&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="nx"&gt;requesterScopes&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="nx"&gt;AGENT_SCOPES&lt;/span&gt;&lt;span class="p"&gt;[&lt;/span&gt;&lt;span class="nx"&gt;step&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;There is no branch here that can widen the set. The result is always a subset of &lt;code&gt;requesterScopes&lt;/code&gt;. A step can ask for the moon; it receives only what the requester had.&lt;/p&gt;

&lt;p&gt;The broken mode is the mirror image. It unions the parent's scopes with the step's needs, with no reference to the requester at all.&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight typescript"&gt;&lt;code&gt;&lt;span class="c1"&gt;// convex/authz.ts: broken mode grows the set, never consulting the requester&lt;/span&gt;
&lt;span class="k"&gt;export&lt;/span&gt; &lt;span class="kd"&gt;function&lt;/span&gt; &lt;span class="nf"&gt;brokenNextScopes&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="nx"&gt;parentScopes&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="kr"&gt;string&lt;/span&gt;&lt;span class="p"&gt;[],&lt;/span&gt; &lt;span class="nx"&gt;need&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="kr"&gt;string&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="p"&gt;[...&lt;/span&gt;&lt;span class="k"&gt;new&lt;/span&gt; &lt;span class="nc"&gt;Set&lt;/span&gt;&lt;span class="p"&gt;([...&lt;/span&gt;&lt;span class="nx"&gt;parentScopes&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="p"&gt;...&lt;/span&gt;&lt;span class="nx"&gt;need&lt;/span&gt;&lt;span class="p"&gt;])];&lt;/span&gt; &lt;span class="c1"&gt;// union, so it can only grow&lt;/span&gt;
&lt;span class="p"&gt;}&lt;/span&gt;
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;Put the two functions side by side and the whole article is visible in a few lines. One intersects with the requester. One ignores the requester and unions. That single difference is the difference between a chain that holds and a chain that leaks.&lt;/p&gt;

&lt;h2&gt;
  
  
  The component refuses to over-issue
&lt;/h2&gt;

&lt;p&gt;The application computes intended scopes, but there is a floor under it. When a hop asks the component to issue the next delegation, the component checks the request against the target agent's registered scopes.&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight typescript"&gt;&lt;code&gt;&lt;span class="c1"&gt;// convex/hop.ts: issuing the hop's delegation through the component&lt;/span&gt;
&lt;span class="k"&gt;await&lt;/span&gt; &lt;span class="nx"&gt;ctx&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nf"&gt;runMutation&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="nx"&gt;components&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nx"&gt;agentAuth&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nx"&gt;delegations&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nx"&gt;issue&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="p"&gt;{&lt;/span&gt;
  &lt;span class="na"&gt;agentId&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="nx"&gt;callerAgentId&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt;        &lt;span class="c1"&gt;// the agent this hop runs as&lt;/span&gt;
  &lt;span class="na"&gt;issuerKind&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="dl"&gt;"&lt;/span&gt;&lt;span class="s2"&gt;user&lt;/span&gt;&lt;span class="dl"&gt;"&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt;            &lt;span class="c1"&gt;// the chain is rooted in the human requester&lt;/span&gt;
  &lt;span class="na"&gt;issuerSubject&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="nx"&gt;requesterSubject&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt;
  &lt;span class="nx"&gt;scopes&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt;                        &lt;span class="c1"&gt;// requester scopes ∩ agent scopes&lt;/span&gt;
  &lt;span class="na"&gt;resources&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="p"&gt;[&lt;/span&gt;&lt;span class="s2"&gt;`run:&lt;/span&gt;&lt;span class="p"&gt;${&lt;/span&gt;&lt;span class="nx"&gt;runId&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="na"&gt;expiresAt&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="nb"&gt;Date&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nf"&gt;now&lt;/span&gt;&lt;span class="p"&gt;()&lt;/span&gt; &lt;span class="o"&gt;+&lt;/span&gt; &lt;span class="nx"&gt;HOUR&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;If &lt;code&gt;scopes&lt;/code&gt; contains a scope the agent is not registered for, the component rejects the call with &lt;code&gt;scopes_exceed_agent&lt;/code&gt;. It will not mint the delegation. This backstops the agent ceiling: no delegation can hold more than the agent itself holds. The human ceiling is enforced one layer up, by the intersection the application ran before this call. Both layers narrow. Neither can widen.&lt;/p&gt;

&lt;h2&gt;
  
  
  Authorizing the order
&lt;/h2&gt;

&lt;p&gt;The ordering agent reaches the final step: place the order. The server finds the tier the amount needs, then asks the component to authorize that action.&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight typescript"&gt;&lt;code&gt;&lt;span class="c1"&gt;// convex/orders.ts: authorize before placing&lt;/span&gt;
&lt;span class="kd"&gt;const&lt;/span&gt; &lt;span class="nx"&gt;requiredAction&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="s2"&gt;`orders:place:&lt;/span&gt;&lt;span class="p"&gt;${&lt;/span&gt;&lt;span class="nf"&gt;orderTier&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="nx"&gt;amountCents&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="c1"&gt;// $142,000 → orders:place:t2&lt;/span&gt;

&lt;span class="kd"&gt;const&lt;/span&gt; &lt;span class="p"&gt;{&lt;/span&gt; &lt;span class="nx"&gt;decision&lt;/span&gt; &lt;span class="p"&gt;}&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="k"&gt;await&lt;/span&gt; &lt;span class="nf"&gt;authorize&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="nx"&gt;ctx&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="nx"&gt;components&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nx"&gt;agentAuth&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="nx"&gt;agentToken&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="p"&gt;{&lt;/span&gt;
  &lt;span class="nx"&gt;instanceId&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt;
  &lt;span class="na"&gt;action&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="nx"&gt;requiredAction&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="p"&gt;(&lt;/span&gt;&lt;span class="o"&gt;!&lt;/span&gt;&lt;span class="nx"&gt;decision&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nx"&gt;allowed&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="p"&gt;{&lt;/span&gt;
    &lt;span class="na"&gt;denied&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="kc"&gt;true&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt;
    &lt;span class="na"&gt;reason&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="nx"&gt;decision&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nx"&gt;reason&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt;             &lt;span class="c1"&gt;// "insufficient_scope"&lt;/span&gt;
    &lt;span class="na"&gt;requiredScopes&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="nx"&gt;decision&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nx"&gt;requiredScopes&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="c1"&gt;// ["orders:place:t2"]&lt;/span&gt;
    &lt;span class="na"&gt;correlationId&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="nx"&gt;decision&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nx"&gt;correlationId&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;The effective scopes for the decision are the intersection of the agent's own scopes and the delegation's scopes. The action passes only if that set contains it. In the attenuated chain rooted in a buyer, the delegation holds &lt;code&gt;orders:place:t1&lt;/code&gt; at most. The action needs &lt;code&gt;orders:place:t2&lt;/code&gt;. The intersection does not contain &lt;code&gt;t2&lt;/code&gt;. The order is refused, no order row is written, and the run ends with a clean terminal event.&lt;/p&gt;

&lt;p&gt;The token proves identity here, not the tier. The ordering agent's own Kinde token stays constant across every run. What changes run to run is the delegation, which carries the person's ceiling. That is why the same agent places a director's order and refuses a buyer's.&lt;/p&gt;

&lt;h2&gt;
  
  
  The trust boundary is real, not a convention
&lt;/h2&gt;

&lt;p&gt;There is a quiet way this whole design can fail. If an agent can call the database directly, or if two agents share one token, the chain means nothing. The demo prevents this with structure, not with a rule in a document.&lt;/p&gt;

&lt;p&gt;The code is split into workspaces. The agents live in a package that does not depend on the Convex client at all. It cannot import what it does not have. Every agent reaches the application the same way a stranger would: over HTTP, with its own bearer token and its own delegation.&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight typescript"&gt;&lt;code&gt;&lt;span class="c1"&gt;// packages/api-client: the only way an agent talks to the app&lt;/span&gt;
&lt;span class="k"&gt;export&lt;/span&gt; &lt;span class="kd"&gt;function&lt;/span&gt; &lt;span class="nf"&gt;createFloorClient&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="nx"&gt;opts&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="p"&gt;{&lt;/span&gt;
  &lt;span class="nl"&gt;agentToken&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="kr"&gt;string&lt;/span&gt;&lt;span class="p"&gt;;&lt;/span&gt;   &lt;span class="c1"&gt;// required, no default&lt;/span&gt;
  &lt;span class="nl"&gt;delegation&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="kr"&gt;string&lt;/span&gt;&lt;span class="p"&gt;;&lt;/span&gt;   &lt;span class="c1"&gt;// required, no default&lt;/span&gt;
  &lt;span class="nl"&gt;baseUrl&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="kr"&gt;string&lt;/span&gt;&lt;span class="p"&gt;;&lt;/span&gt;
&lt;span class="p"&gt;}):&lt;/span&gt; &lt;span class="nx"&gt;FloorClient&lt;/span&gt;&lt;span class="p"&gt;;&lt;/span&gt;
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;There is no ambient token and no shortcut. An agent that has no token cannot call the app. A continuous integration check runs on every change. It fails the build if the agents package ever imports the database client, and it fails if one recorded run does not carry three distinct agent identities. The boundary holds because the build will not pass without it.&lt;/p&gt;

&lt;h2&gt;
  
  
  Broken versus attenuated, by role
&lt;/h2&gt;

&lt;p&gt;The clearest way to see the fix is to run the same requisition as each role, in each mode. The requisition lands at a $142,000 winning quote, which needs &lt;code&gt;orders:place:t2&lt;/code&gt;.&lt;/p&gt;

&lt;p&gt;Attenuated mode. The order is placed only when the person who started the run could have approved it:&lt;/p&gt;

&lt;div class="table-wrapper-paragraph"&gt;&lt;table&gt;
&lt;thead&gt;
&lt;tr&gt;
&lt;th&gt;Role starts the run&lt;/th&gt;
&lt;th&gt;Highest tier the chain holds&lt;/th&gt;
&lt;th&gt;$142,000 order&lt;/th&gt;
&lt;/tr&gt;
&lt;/thead&gt;
&lt;tbody&gt;
&lt;tr&gt;
&lt;td&gt;Requester&lt;/td&gt;
&lt;td&gt;none&lt;/td&gt;
&lt;td&gt;refused&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;Buyer&lt;/td&gt;
&lt;td&gt;
&lt;code&gt;t1&lt;/code&gt; ($50,000)&lt;/td&gt;
&lt;td&gt;refused&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;Director&lt;/td&gt;
&lt;td&gt;
&lt;code&gt;t3&lt;/code&gt; (no ceiling)&lt;/td&gt;
&lt;td&gt;placed&lt;/td&gt;
&lt;/tr&gt;
&lt;/tbody&gt;
&lt;/table&gt;&lt;/div&gt;

&lt;p&gt;Broken mode. The order is placed every time, because each chain grew to fit the task:&lt;/p&gt;

&lt;div class="table-wrapper-paragraph"&gt;&lt;table&gt;
&lt;thead&gt;
&lt;tr&gt;
&lt;th&gt;Role starts the run&lt;/th&gt;
&lt;th&gt;What the chain does&lt;/th&gt;
&lt;th&gt;$142,000 order&lt;/th&gt;
&lt;/tr&gt;
&lt;/thead&gt;
&lt;tbody&gt;
&lt;tr&gt;
&lt;td&gt;Requester&lt;/td&gt;
&lt;td&gt;grows past the requester&lt;/td&gt;
&lt;td&gt;placed&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;Buyer&lt;/td&gt;
&lt;td&gt;grows past the buyer&lt;/td&gt;
&lt;td&gt;placed&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;Director&lt;/td&gt;
&lt;td&gt;grows, but was already allowed&lt;/td&gt;
&lt;td&gt;placed&lt;/td&gt;
&lt;/tr&gt;
&lt;/tbody&gt;
&lt;/table&gt;&lt;/div&gt;

&lt;p&gt;Read the last row of the broken table carefully. In broken mode, a director's run also places the order, and that result is correct. But it is correct by accident. The chain did not place the order because the director was allowed to. It placed the order because it places every order. The right outcome and the wrong mechanism can look identical from the outside. That is why a leak like this survives in production. On the happy path, broken and correct give the same answer. You only see the difference when the wrong person starts the run.&lt;/p&gt;

&lt;p&gt;&lt;a href="https://media2.dev.to/dynamic/image/width=800%2Cheight=%2Cfit=scale-down%2Cgravity=auto%2Cformat=auto/https%3A%2F%2Fdev-to-uploads.s3.us-east-2.amazonaws.com%2Fuploads%2Farticles%2Fj6keeg6glm1xt1laoqa1.png" class="article-body-image-wrapper"&gt;&lt;img src="https://media2.dev.to/dynamic/image/width=800%2Cheight=%2Cfit=scale-down%2Cgravity=auto%2Cformat=auto/https%3A%2F%2Fdev-to-uploads.s3.us-east-2.amazonaws.com%2Fuploads%2Farticles%2Fj6keeg6glm1xt1laoqa1.png" alt="An approved run: a Director clears the same $142,000 order" width="800" height="1029"&gt;&lt;/a&gt;&lt;/p&gt;

&lt;h2&gt;
  
  
  What to take from this
&lt;/h2&gt;

&lt;p&gt;Three points hold whatever stack you use.&lt;/p&gt;

&lt;p&gt;&lt;strong&gt;Root the chain in the human.&lt;/strong&gt; Authority in an agent run belongs to the person who started it. Every hop is a subset of what that person holds. If your chain does not trace back to a real person's scopes, it has no ceiling.&lt;/p&gt;

&lt;p&gt;&lt;strong&gt;Intersect, do not grant.&lt;/strong&gt; A handoff should narrow authority, never size it to the task. The moment a handoff grants what the next step needs, without checking the person above, the chain can grow, and a grown chain is a leak waiting for the wrong caller.&lt;/p&gt;

&lt;p&gt;&lt;strong&gt;Enforce at the source, not at the end.&lt;/strong&gt; A check before the final action cannot catch authority that was widened along the way, because widened authority looks correct by the time you check it. Put the enforcement where delegations are minted, so the over-wide scope never enters the chain.&lt;/p&gt;

&lt;h2&gt;
  
  
  Try it and read the code
&lt;/h2&gt;

&lt;ul&gt;
&lt;li&gt;The live demo: &lt;a href="https://procurement-floor-demo.vercel.app/" rel="noopener noreferrer"&gt;procurement-floor-demo.vercel.app&lt;/a&gt;
&lt;/li&gt;
&lt;li&gt;The source: &lt;a href="https://github.com/kinde-starter-kits/procurement-floor-demo" rel="noopener noreferrer"&gt;github.com/kinde-starter-kits/procurement-floor-demo&lt;/a&gt;
&lt;/li&gt;
&lt;/ul&gt;

&lt;p&gt;Pick a role, write a requisition, and start a run. Watch the delegation chain and the timeline stream as the agents work. Start a run as a buyer and watch the large order get refused at the last hop. Start the same run as a director and watch it go through. The chain shows every scope at every hop, and the record shows every decision.&lt;/p&gt;

&lt;p&gt;One note on the hosted demo. The agent graph runs in a Trigger.dev task, and that task needs a worker. A plain web deploy starts a run but cannot finish it on its own, so the hosted demo needs the task deployed to Trigger.dev, or a worker running beside it. To see a full run end to end with no setup, clone the repo and run it locally. The README has the steps.&lt;/p&gt;

</description>
      <category>ai</category>
      <category>kinde</category>
      <category>agents</category>
      <category>architecture</category>
    </item>
    <item>
      <title>How to Fix the Confused Deputy Problem in AI Agents</title>
      <dc:creator>Shola Jegede</dc:creator>
      <pubDate>Fri, 07 Aug 2026 13:42:11 +0000</pubDate>
      <link>https://dev.to/sholajegede/how-to-fix-the-confused-deputy-problem-in-ai-agents-with-permission-intersection-1md1</link>
      <guid>https://dev.to/sholajegede/how-to-fix-the-confused-deputy-problem-in-ai-agents-with-permission-intersection-1md1</guid>
      <description>&lt;p&gt;&lt;em&gt;An AI agent runs with its own permissions. So it can be tricked into doing something its user is not allowed to do. This is the confused deputy problem. This article shows the failure in a real contract-review agent, then fixes it with one rule: permission intersection, checked at the action.&lt;/em&gt;&lt;/p&gt;

&lt;p&gt;&lt;a href="https://media2.dev.to/dynamic/image/width=800%2Cheight=%2Cfit=scale-down%2Cgravity=auto%2Cformat=auto/https%3A%2F%2Fdev-to-uploads.s3.us-east-2.amazonaws.com%2Fuploads%2Farticles%2F9t8tleonqbtza81jxw8i.png" class="article-body-image-wrapper"&gt;&lt;img src="https://media2.dev.to/dynamic/image/width=800%2Cheight=%2Cfit=scale-down%2Cgravity=auto%2Cformat=auto/https%3A%2F%2Fdev-to-uploads.s3.us-east-2.amazonaws.com%2Fuploads%2Farticles%2F9t8tleonqbtza81jxw8i.png" alt="" width="800" height="665"&gt;&lt;/a&gt;&lt;/p&gt;

&lt;p&gt;An intern at a law firm opens a contract review tool. Their account has one permission: read. They cannot flag a clause. They cannot approve one. The tool shows this on screen, next to their name.&lt;/p&gt;

&lt;p&gt;The intern asks an AI agent to review a supplier contract. It is a Master Services Agreement from a cloud vendor, 14 clauses long. Normal work. The agent starts.&lt;/p&gt;

&lt;p&gt;The agent reads each clause and rates its risk. Clause 3 caps nothing: the customer's liability is uncapped. That is critical. Clause 9 removes the right to join a class action. Also critical. Six more clauses are high risk: automatic renewal, broad indemnification, GDPR duties, HIPAA duties, IP assignment, and a non-compete.&lt;/p&gt;

&lt;p&gt;Then the agent signs off. It approves all 14 clauses. It approves both critical ones.&lt;/p&gt;

&lt;p&gt;Look at the screen again. The user is an intern. The badge says: Read yes. Flag no. Approve no. The agent just approved a contract with uncapped liability on that person's behalf.&lt;/p&gt;

&lt;p&gt;Nobody attacked this system. Nobody stole a token. The intern asked an agent to do its job, and the agent used its own permissions to do something the intern could never do.&lt;/p&gt;

&lt;p&gt;This is the &lt;strong&gt;confused deputy problem&lt;/strong&gt;. It is old. AI agents have made it dangerous again.&lt;/p&gt;

&lt;p&gt;&lt;a href="https://media2.dev.to/dynamic/image/width=800%2Cheight=%2Cfit=scale-down%2Cgravity=auto%2Cformat=auto/https%3A%2F%2Fdev-to-uploads.s3.us-east-2.amazonaws.com%2Fuploads%2Farticles%2Fmdoiv18sah244to9hazf.png" class="article-body-image-wrapper"&gt;&lt;img src="https://media2.dev.to/dynamic/image/width=800%2Cheight=%2Cfit=scale-down%2Cgravity=auto%2Cformat=auto/https%3A%2F%2Fdev-to-uploads.s3.us-east-2.amazonaws.com%2Fuploads%2Farticles%2Fmdoiv18sah244to9hazf.png" alt="The demo in broken mode. The Current User badge shows Intern with Read granted, Flag denied, Approve denied. The verdict panel reads " width="800" height="665"&gt;&lt;/a&gt;&lt;/p&gt;

&lt;p&gt;Every screenshot in this article comes from a live demo. You can run it yourself. The link is at the end.&lt;/p&gt;

&lt;h2&gt;
  
  
  What the confused deputy problem is
&lt;/h2&gt;

&lt;p&gt;The name comes from computer security. A &lt;em&gt;deputy&lt;/em&gt; is a program that acts for someone else. A &lt;em&gt;confused&lt;/em&gt; deputy is a program that gets tricked into using its own authority for a caller who does not have that authority.&lt;/p&gt;

&lt;p&gt;The shape is always the same:&lt;/p&gt;

&lt;ol&gt;
&lt;li&gt;A program holds a strong permission.&lt;/li&gt;
&lt;li&gt;A weaker caller asks the program to do a task.&lt;/li&gt;
&lt;li&gt;The program does the task with &lt;strong&gt;its own&lt;/strong&gt; permission.&lt;/li&gt;
&lt;li&gt;The caller has now done something they could not do alone.&lt;/li&gt;
&lt;/ol&gt;

&lt;p&gt;Security researchers described this decades ago. For years it was a problem for compilers, print servers, and file systems. The deputy was a piece of infrastructure.&lt;/p&gt;

&lt;p&gt;Today the deputy is an AI agent. The authority is the power to act inside your product.&lt;/p&gt;

&lt;p&gt;An agent is a deputy by design. You give it a task. It acts for you. To be useful it needs permissions. It reads your data, calls your tools, and often changes or approves something. The moment it acts, your application sees the &lt;strong&gt;agent's&lt;/strong&gt; identity and the &lt;strong&gt;agent's&lt;/strong&gt; permissions. The person who asked is no longer part of the decision.&lt;/p&gt;

&lt;h2&gt;
  
  
  Why AI agents make this worse
&lt;/h2&gt;

&lt;p&gt;Three things turn an old problem into a present danger.&lt;/p&gt;

&lt;h3&gt;
  
  
  Agents hold broad permissions
&lt;/h3&gt;

&lt;p&gt;A useful agent does many things, so teams give it wide access. A contract review agent can read, flag, and approve, because all three belong to review.&lt;/p&gt;

&lt;p&gt;That breadth is the point of the agent. It is also the risk. The agent's permission set is usually the &lt;em&gt;union&lt;/em&gt; of every permission any user of the feature might need. It is almost never the permission set of the person who triggered the run.&lt;/p&gt;

&lt;h3&gt;
  
  
  The human disappears at the moment of action
&lt;/h3&gt;

&lt;p&gt;A user signs in. Your app checks who they are and what they can do. That check is correct, and it happens at the wrong time.&lt;/p&gt;

&lt;p&gt;The user then asks the agent to work. The agent gets a machine token and calls your API. That call carries the agent's identity. Your sign-in check told you about a human who is no longer in the request.&lt;/p&gt;

&lt;p&gt;Login answers "who are you?". It does not answer "may this action happen?". Those are different questions, asked at different moments.&lt;/p&gt;

&lt;h3&gt;
  
  
  Agent crews widen the gap
&lt;/h3&gt;

&lt;p&gt;Modern systems rarely use one agent. They use a crew. One agent extracts the clauses. A second rates the risk. A third signs off.&lt;/p&gt;

&lt;p&gt;Each hand-off is a place to lose the user's limits. By the time the last agent acts, nothing in the chain remembers that a read-only intern started the job. The final action is several steps away from the person who asked for it.&lt;/p&gt;

&lt;p&gt;Now add the stakes. Agents are moving into contracts, finance, healthcare, and operations. These are the exact places with an approval step, a spend limit, or a sign-off. A confused deputy in a chat toy is a joke. A confused deputy that approves uncapped liability is a lawsuit.&lt;/p&gt;

&lt;p&gt;The diagram below shows where the user's limit is lost.&lt;/p&gt;

&lt;p&gt;&lt;a href="https://media2.dev.to/dynamic/image/width=800%2Cheight=%2Cfit=scale-down%2Cgravity=auto%2Cformat=auto/https%3A%2F%2Fdev-to-uploads.s3.us-east-2.amazonaws.com%2Fuploads%2Farticles%2Fth1785bb8oyhy11zf8fy.png" class="article-body-image-wrapper"&gt;&lt;img src="https://media2.dev.to/dynamic/image/width=800%2Cheight=%2Cfit=scale-down%2Cgravity=auto%2Cformat=auto/https%3A%2F%2Fdev-to-uploads.s3.us-east-2.amazonaws.com%2Fuploads%2Farticles%2Fth1785bb8oyhy11zf8fy.png" alt="The intern asks the agent crew to work. The crew reaches the protected action&lt;br&gt;
  under its own identity. The intern's authority stops at the crew, so the action&lt;br&gt;
  happens without their permission." width="800" height="69"&gt;&lt;/a&gt;&lt;/p&gt;

&lt;p&gt;&lt;em&gt;The user's limit is lost the moment the agent acts. The agent reaches the protected action under its own authority.&lt;/em&gt;&lt;/p&gt;
&lt;h2&gt;
  
  
  The failure in numbers
&lt;/h2&gt;

&lt;p&gt;Words make this sound theoretical. Numbers do not.&lt;/p&gt;

&lt;p&gt;Here is one run of that contract review. A read-only intern is the acting user. The system authorizes on the agent's identity alone.&lt;/p&gt;

&lt;div class="table-wrapper-paragraph"&gt;&lt;table&gt;
&lt;thead&gt;
&lt;tr&gt;
&lt;th&gt;Measure&lt;/th&gt;
&lt;th&gt;Result&lt;/th&gt;
&lt;/tr&gt;
&lt;/thead&gt;
&lt;tbody&gt;
&lt;tr&gt;
&lt;td&gt;Clauses in the contract&lt;/td&gt;
&lt;td&gt;14&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;Approvals attempted&lt;/td&gt;
&lt;td&gt;14&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;Approvals allowed&lt;/td&gt;
&lt;td&gt;14&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;Approvals blocked&lt;/td&gt;
&lt;td&gt;0&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;Privilege escalations&lt;/td&gt;
&lt;td&gt;&lt;strong&gt;14&lt;/strong&gt;&lt;/td&gt;
&lt;/tr&gt;
&lt;/tbody&gt;
&lt;/table&gt;&lt;/div&gt;

&lt;p&gt;These numbers come from the demo's deterministic run. In that run the sign-off agent attempts to approve every clause, like a plausible "auto-approve this contract" agent. That is what makes the confused deputy visible in one screen.&lt;/p&gt;

&lt;p&gt;Fourteen privilege escalations in one review. Each one is an action the acting user had no right to take. Two of them approved critical clauses.&lt;/p&gt;

&lt;p&gt;The system did not fail. It worked exactly as written. It checked whether the &lt;em&gt;agent&lt;/em&gt; could approve, and the agent could. That is the whole bug.&lt;/p&gt;

&lt;p&gt;The agent still scores the risk correctly. It labels the uncapped-liability clause CRITICAL and the class-action waiver CRITICAL. Then it approves them anyway, because risk scoring and authorization are different jobs. Scoring tells you a clause is dangerous. Authorization decides whether this user may sign off. Broken mode does the first and skips the second.&lt;/p&gt;

&lt;p&gt;&lt;a href="https://media2.dev.to/dynamic/image/width=800%2Cheight=%2Cfit=scale-down%2Cgravity=auto%2Cformat=auto/https%3A%2F%2Fdev-to-uploads.s3.us-east-2.amazonaws.com%2Fuploads%2Farticles%2Fjhs79tccefvl5e5wtk9w.png" class="article-body-image-wrapper"&gt;&lt;img src="https://media2.dev.to/dynamic/image/width=800%2Cheight=%2Cfit=scale-down%2Cgravity=auto%2Cformat=auto/https%3A%2F%2Fdev-to-uploads.s3.us-east-2.amazonaws.com%2Fuploads%2Farticles%2Fjhs79tccefvl5e5wtk9w.png" alt="The live review timeline in broken mode. Clause 3 shows " width="800" height="554"&gt;&lt;/a&gt;&lt;/p&gt;
&lt;h2&gt;
  
  
  Why the obvious fixes do not work
&lt;/h2&gt;

&lt;p&gt;Teams meet this problem and reach for three fixes. Each one feels right. Each one fails. The failures are useful, because they point at the answer.&lt;/p&gt;
&lt;h3&gt;
  
  
  "Give the agent a role"
&lt;/h3&gt;

&lt;p&gt;The idea: create a role for the agent and set its permissions with care.&lt;/p&gt;

&lt;p&gt;This gives the agent &lt;strong&gt;one&lt;/strong&gt; ceiling. That ceiling is the same for every user the agent serves. A read-only intern and a senior admin trigger the same agent, and the agent acts at its role's level for both.&lt;/p&gt;

&lt;p&gt;You can lower the ceiling, but you cannot shape it per user. Lower it enough to stop the intern, and the admin can no longer get work done. Raise it enough for the admin, and the intern can reach the same power. The agent's role says nothing about the person who asked.&lt;/p&gt;
&lt;h3&gt;
  
  
  "Check permissions at sign-in"
&lt;/h3&gt;

&lt;p&gt;The idea: the app already checks the user at login, so the user is controlled.&lt;/p&gt;

&lt;p&gt;Sign-in and action are separate moments. The agent acts later, under a machine identity, in its own request. The sign-in check never sees that request.&lt;/p&gt;

&lt;p&gt;This is the security equivalent of checking a ticket at the front gate and leaving every internal door unlocked.&lt;/p&gt;
&lt;h3&gt;
  
  
  "Trust the agent's own scopes"
&lt;/h3&gt;

&lt;p&gt;The idea: the agent's token has scopes, so let the token decide.&lt;/p&gt;

&lt;p&gt;This one is the most dangerous, because it looks like real authorization. There is a token. There are scopes. There is a check that can fail.&lt;/p&gt;

&lt;p&gt;The problem is what the check computes. The agent's scopes describe the &lt;strong&gt;agent's&lt;/strong&gt; authority. When the agent's token alone decides, your effective permission is the &lt;em&gt;union&lt;/em&gt; of the agent's power and the user's request. Union is the wrong operation. It hands the caller everything the agent holds.&lt;/p&gt;

&lt;p&gt;Each failed fix makes the same mistake in a different place. Each one checks the user or the agent, never both, at the moment that counts.&lt;/p&gt;
&lt;h2&gt;
  
  
  The principle that fixes it
&lt;/h2&gt;

&lt;p&gt;Name the mistake and the rule writes itself.&lt;/p&gt;

&lt;p&gt;&lt;strong&gt;An agent must never exceed the permissions of the person it acts for.&lt;/strong&gt;&lt;/p&gt;

&lt;p&gt;State it as set logic, because that is what it is. The agent's effective permission for an action is the &lt;strong&gt;intersection&lt;/strong&gt; of two sets: what the user may do, and what the agent may do.&lt;/p&gt;

&lt;p&gt;Not the union. The intersection.&lt;/p&gt;

&lt;p&gt;Read that against the three failures:&lt;/p&gt;

&lt;ul&gt;
&lt;li&gt;A role gives one ceiling. Intersection gives a ceiling per user.&lt;/li&gt;
&lt;li&gt;A login check guards the wrong moment. Intersection guards the action.&lt;/li&gt;
&lt;li&gt;The agent's scopes alone give the union. Intersection gives the smaller of the two.&lt;/li&gt;
&lt;/ul&gt;

&lt;p&gt;One part remains, and the failed fixes all miss it. The check runs &lt;strong&gt;at the action&lt;/strong&gt;, on every action. The agent approves a clause, so you check user ∩ agent, right there, before the approval lands. The next clause runs the check again. Fourteen clauses, fourteen checks.&lt;/p&gt;

&lt;p&gt;That is permission intersection. The idea is small and it closes the hole cleanly. An intern with read-only access, working through the most powerful agent you own, still cannot approve a clause. Their ceiling holds, because the agent can never exceed it.&lt;/p&gt;

&lt;p&gt;Run the same contract again with intersection in place. Same intern. Same agent:&lt;/p&gt;

&lt;div class="table-wrapper-paragraph"&gt;&lt;table&gt;
&lt;thead&gt;
&lt;tr&gt;
&lt;th&gt;Measure&lt;/th&gt;
&lt;th&gt;Result&lt;/th&gt;
&lt;/tr&gt;
&lt;/thead&gt;
&lt;tbody&gt;
&lt;tr&gt;
&lt;td&gt;Clauses in the contract&lt;/td&gt;
&lt;td&gt;14&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;Approvals attempted&lt;/td&gt;
&lt;td&gt;14&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;Approvals allowed&lt;/td&gt;
&lt;td&gt;&lt;strong&gt;0&lt;/strong&gt;&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;Approvals blocked&lt;/td&gt;
&lt;td&gt;&lt;strong&gt;14&lt;/strong&gt;&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;Privilege escalations&lt;/td&gt;
&lt;td&gt;&lt;strong&gt;0&lt;/strong&gt;&lt;/td&gt;
&lt;/tr&gt;
&lt;/tbody&gt;
&lt;/table&gt;&lt;/div&gt;

&lt;p&gt;Same agent. Same contract. Same user. One rule changed.&lt;/p&gt;

&lt;p&gt;&lt;a href="https://media2.dev.to/dynamic/image/width=800%2Cheight=%2Cfit=scale-down%2Cgravity=auto%2Cformat=auto/https%3A%2F%2Fdev-to-uploads.s3.us-east-2.amazonaws.com%2Fuploads%2Farticles%2Ff8udlr6dg6yajqe8qzd3.png" class="article-body-image-wrapper"&gt;&lt;img src="https://media2.dev.to/dynamic/image/width=800%2Cheight=%2Cfit=scale-down%2Cgravity=auto%2Cformat=auto/https%3A%2F%2Fdev-to-uploads.s3.us-east-2.amazonaws.com%2Fuploads%2Farticles%2Ff8udlr6dg6yajqe8qzd3.png" alt="The same run in intersection mode. The verdict reads " width="800" height="665"&gt;&lt;/a&gt;&lt;/p&gt;
&lt;h2&gt;
  
  
  What intersection means in practice
&lt;/h2&gt;

&lt;p&gt;Two sets, one operation.&lt;/p&gt;

&lt;ul&gt;
&lt;li&gt;
&lt;strong&gt;The user's set.&lt;/strong&gt; What this person may do, right now, in this organization. For our intern: &lt;code&gt;contracts:read&lt;/code&gt;.&lt;/li&gt;
&lt;li&gt;
&lt;strong&gt;The agent's set.&lt;/strong&gt; What the agent may do at all. For our contract crew: &lt;code&gt;contracts:read&lt;/code&gt;, &lt;code&gt;clauses:flag&lt;/code&gt;, &lt;code&gt;clauses:approve&lt;/code&gt;.&lt;/li&gt;
&lt;/ul&gt;

&lt;p&gt;The agent's effective permission for one action is the overlap:&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight plaintext"&gt;&lt;code&gt;user ∩ agent
{contracts:read} ∩ {contracts:read, clauses:flag, clauses:approve}
= {contracts:read}
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;The agent may read. It may not flag. It may not approve. The powerful agent, acting for a read-only user, becomes a read-only agent.&lt;/p&gt;

&lt;p&gt;Change the user and the answer changes with it:&lt;/p&gt;

&lt;div class="table-wrapper-paragraph"&gt;&lt;table&gt;
&lt;thead&gt;
&lt;tr&gt;
&lt;th&gt;Acting user&lt;/th&gt;
&lt;th&gt;User's permissions&lt;/th&gt;
&lt;th&gt;Effective permission for the agent&lt;/th&gt;
&lt;/tr&gt;
&lt;/thead&gt;
&lt;tbody&gt;
&lt;tr&gt;
&lt;td&gt;Intern&lt;/td&gt;
&lt;td&gt;read&lt;/td&gt;
&lt;td&gt;read&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;Analyst&lt;/td&gt;
&lt;td&gt;read, flag&lt;/td&gt;
&lt;td&gt;read, flag&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;Admin&lt;/td&gt;
&lt;td&gt;read, flag, approve&lt;/td&gt;
&lt;td&gt;read, flag, approve&lt;/td&gt;
&lt;/tr&gt;
&lt;/tbody&gt;
&lt;/table&gt;&lt;/div&gt;

&lt;p&gt;One agent. Three ceilings. The agent does not change. The person it acts for changes, so its power changes.&lt;/p&gt;

&lt;p&gt;This is why intersection beats the three fixes above. A role gives the agent a fixed ceiling. Intersection gives it a ceiling that moves with the user.&lt;/p&gt;

&lt;p&gt;&lt;a href="https://media2.dev.to/dynamic/image/width=800%2Cheight=%2Cfit=scale-down%2Cgravity=auto%2Cformat=auto/https%3A%2F%2Fdev-to-uploads.s3.us-east-2.amazonaws.com%2Fuploads%2Farticles%2Fln3ssxgll6ud5ikbbemd.png" class="article-body-image-wrapper"&gt;&lt;img src="https://media2.dev.to/dynamic/image/width=800%2Cheight=%2Cfit=scale-down%2Cgravity=auto%2Cformat=auto/https%3A%2F%2Fdev-to-uploads.s3.us-east-2.amazonaws.com%2Fuploads%2Farticles%2Fln3ssxgll6ud5ikbbemd.png" alt="Two permission sets overlap: the intern holds contracts:read; the crew holds&lt;br&gt;
  contracts:read, clauses:flag, clauses:approve. The effective permission is the&lt;br&gt;
  overlap, contracts:read. clauses:approve sits outside it, so the agent cannot&lt;br&gt;
  approve for this user." width="800" height="119"&gt;&lt;/a&gt;&lt;/p&gt;

&lt;p&gt;&lt;em&gt;The agent gets the overlap, never the union. &lt;code&gt;clauses:approve&lt;/code&gt; sits outside the overlap, so the agent cannot approve for this user.&lt;/em&gt;&lt;/p&gt;
&lt;h2&gt;
  
  
  The parts you need
&lt;/h2&gt;

&lt;p&gt;Five parts. Each one is small. The value comes from putting them in the right places.&lt;/p&gt;
&lt;h3&gt;
  
  
  1. A real user identity, with live permissions
&lt;/h3&gt;

&lt;p&gt;Your identity provider already knows who the user is and what they may do. That is the ceiling. Read it from the provider at the time of the run, not from a copy in your database and not from a list in your code. A permission that changed this morning must apply this afternoon.&lt;/p&gt;
&lt;h3&gt;
  
  
  2. A real agent identity
&lt;/h3&gt;

&lt;p&gt;The agent gets its own machine credentials and its own token. This matters for a reason people miss: the agent needs an identity so you can &lt;em&gt;limit&lt;/em&gt; it, and so every action it takes has a name attached in your logs.&lt;/p&gt;

&lt;p&gt;Two machine identities are usually correct here, not one:&lt;/p&gt;

&lt;ul&gt;
&lt;li&gt;
&lt;strong&gt;The agent's identity.&lt;/strong&gt; The crew uses this to call your API. It is the "who is this caller" credential.&lt;/li&gt;
&lt;li&gt;
&lt;strong&gt;A management identity.&lt;/strong&gt; Your server uses this to ask the identity provider what a given user may do. It is the "what may this person do" credential.&lt;/li&gt;
&lt;/ul&gt;

&lt;p&gt;They are separate because they do different jobs, and because the agent must never hold the power to read every user's permissions.&lt;/p&gt;

&lt;p&gt;&lt;a href="https://media2.dev.to/dynamic/image/width=800%2Cheight=%2Cfit=scale-down%2Cgravity=auto%2Cformat=auto/https%3A%2F%2Fdev-to-uploads.s3.us-east-2.amazonaws.com%2Fuploads%2Farticles%2Fda3amtqho9ikmyo6nm5w.png" class="article-body-image-wrapper"&gt;&lt;img src="https://media2.dev.to/dynamic/image/width=800%2Cheight=%2Cfit=scale-down%2Cgravity=auto%2Cformat=auto/https%3A%2F%2Fdev-to-uploads.s3.us-east-2.amazonaws.com%2Fuploads%2Farticles%2Fda3amtqho9ikmyo6nm5w.png" alt="Two machine identities in Kinde: the crew application that calls the API, and the management application the server uses to read a user's permissions." width="800" height="257"&gt;&lt;/a&gt;&lt;/p&gt;

&lt;p&gt;&lt;a href="https://media2.dev.to/dynamic/image/width=800%2Cheight=%2Cfit=scale-down%2Cgravity=auto%2Cformat=auto/https%3A%2F%2Fdev-to-uploads.s3.us-east-2.amazonaws.com%2Fuploads%2Farticles%2F7enp6mqe8cxc0mpigqz2.png" class="article-body-image-wrapper"&gt;&lt;img src="https://media2.dev.to/dynamic/image/width=800%2Cheight=%2Cfit=scale-down%2Cgravity=auto%2Cformat=auto/https%3A%2F%2Fdev-to-uploads.s3.us-east-2.amazonaws.com%2Fuploads%2Farticles%2F7enp6mqe8cxc0mpigqz2.png" alt="The three permissions the demo uses, defined in Kinde." width="800" height="497"&gt;&lt;/a&gt;&lt;/p&gt;
&lt;h3&gt;
  
  
  3. The acting user, carried on every request
&lt;/h3&gt;

&lt;p&gt;This is the part most systems drop. When the agent calls your API, the request must say &lt;strong&gt;who it acts for&lt;/strong&gt;, not only who it is.&lt;/p&gt;

&lt;p&gt;In practice that is one field: the user's stable identifier, sent with each call. Your server reads it, and never lets the client choose it freely. Derive it from the session that started the run.&lt;/p&gt;
&lt;h3&gt;
  
  
  4. One authorization check at the action boundary
&lt;/h3&gt;

&lt;p&gt;Not at login. Not at the start of the run. At the action.&lt;/p&gt;

&lt;p&gt;The agent asks to approve clause 3. Before that approval lands, your server asks one question: may this agent, acting for this user, take this action? One function, one answer.&lt;/p&gt;
&lt;h3&gt;
  
  
  5. A record of every decision
&lt;/h3&gt;

&lt;p&gt;Every allow and every deny writes one row: what was decided, which action, why, which ceiling applied, and an identifier that ties the row to the request. This is what turns "trust us" into "check for yourself".&lt;/p&gt;
&lt;h2&gt;
  
  
  The check, step by step
&lt;/h2&gt;

&lt;p&gt;Here is the full path of one approval, from the agent's request to the answer.&lt;/p&gt;

&lt;p&gt;&lt;strong&gt;Step 1. The agent authenticates.&lt;/strong&gt; The crew requests a token with its own machine credentials, for your API's audience. This token says "I am the contract review crew". It says nothing about any user.&lt;/p&gt;

&lt;p&gt;&lt;strong&gt;Step 2. The agent calls your API and names the user.&lt;/strong&gt; The request carries the token and the acting user's identifier. In the demo that is a header, &lt;code&gt;X-Acting-Subject&lt;/code&gt;. Your endpoint reads both.&lt;/p&gt;

&lt;p&gt;&lt;strong&gt;Step 3. Your server verifies the caller.&lt;/strong&gt; The component checks the token's signature against the provider's keys, checks the audience, and resolves the caller to a registered agent. A bad token stops here.&lt;/p&gt;

&lt;p&gt;&lt;strong&gt;Step 4. Your server resolves the user's ceiling.&lt;/strong&gt; Using the management identity, it asks the identity provider what this user may do in this organization. The answer for our intern is one permission: &lt;code&gt;contracts:read&lt;/code&gt;. The server attaches this ceiling to the run, so every later action in the run uses it.&lt;/p&gt;

&lt;p&gt;&lt;strong&gt;Step 5. Your server authorizes the action.&lt;/strong&gt; This is the whole fix, in one call:&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight typescript"&gt;&lt;code&gt;&lt;span class="c1"&gt;// At the action boundary, not at login.&lt;/span&gt;
&lt;span class="kd"&gt;const&lt;/span&gt; &lt;span class="p"&gt;{&lt;/span&gt; &lt;span class="nx"&gt;decision&lt;/span&gt; &lt;span class="p"&gt;}&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="k"&gt;await&lt;/span&gt; &lt;span class="nx"&gt;agentAuth&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nf"&gt;authorize&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="nx"&gt;ctx&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="nx"&gt;token&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="p"&gt;{&lt;/span&gt;
  &lt;span class="nx"&gt;instanceId&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt;                    &lt;span class="c1"&gt;// this run, tied to the acting user&lt;/span&gt;
  &lt;span class="na"&gt;action&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="dl"&gt;'&lt;/span&gt;&lt;span class="s1"&gt;clauses:approve&lt;/span&gt;&lt;span class="dl"&gt;'&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt;     &lt;span class="c1"&gt;// the action being attempted&lt;/span&gt;
  &lt;span class="na"&gt;enforceTokenScopes&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="kc"&gt;true&lt;/span&gt;       &lt;span class="c1"&gt;// fold the live token scopes in too&lt;/span&gt;
&lt;span class="p"&gt;});&lt;/span&gt;

&lt;span class="k"&gt;if &lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="o"&gt;!&lt;/span&gt;&lt;span class="nx"&gt;decision&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nx"&gt;allowed&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="nf"&gt;deny&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="nx"&gt;decision&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nx"&gt;reason&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="nx"&gt;decision&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nx"&gt;correlationId&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;Inside, the component computes the overlap:&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight plaintext"&gt;&lt;code&gt;agent's registered scopes
  ∩ the acting user's ceiling
  ∩ the live token's scopes
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;Three sets, not two. The third one matters. If you shrink the agent's credentials in your provider, the next token carries less, and the decision tightens at once. You do not redeploy anything.&lt;/p&gt;

&lt;p&gt;&lt;strong&gt;Step 6a. Allowed.&lt;/strong&gt; The action runs. One audit row records the allow.&lt;/p&gt;

&lt;p&gt;&lt;strong&gt;Step 6b. Denied.&lt;/strong&gt; The action &lt;strong&gt;does not run&lt;/strong&gt;. The endpoint answers with a machine-readable reason, and one audit row records the deny:&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;"error"&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt;&lt;span class="w"&gt; &lt;/span&gt;&lt;span class="s2"&gt;"authorization_denied"&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt;&lt;span class="w"&gt;
  &lt;/span&gt;&lt;span class="nl"&gt;"reason"&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt;&lt;span class="w"&gt; &lt;/span&gt;&lt;span class="s2"&gt;"insufficient_scope"&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt;&lt;span class="w"&gt;
  &lt;/span&gt;&lt;span class="nl"&gt;"requiredScopes"&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="s2"&gt;"clauses:approve"&lt;/span&gt;&lt;span class="p"&gt;],&lt;/span&gt;&lt;span class="w"&gt;
  &lt;/span&gt;&lt;span class="nl"&gt;"correlationId"&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt;&lt;span class="w"&gt; &lt;/span&gt;&lt;span class="s2"&gt;"1c3f3d4e-1c6a-4810-958a-ab125d4fc027"&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;Read that response. It does not say "something went wrong". It says which action was refused, which permission was missing, and gives an identifier you can look up. A support engineer can answer "why did this fail?" without a debugger.&lt;/p&gt;

&lt;p&gt;&lt;strong&gt;Step 7. Repeat.&lt;/strong&gt; The next clause runs the same check. Fourteen clauses, fourteen decisions, fourteen rows.&lt;/p&gt;

&lt;h2&gt;
  
  
  The audit trail
&lt;/h2&gt;

&lt;p&gt;Enforcement stops the bad action. The audit trail is how anyone else believes you.&lt;/p&gt;

&lt;p&gt;Each decision writes one row. The useful fields are these:&lt;/p&gt;

&lt;div class="table-wrapper-paragraph"&gt;&lt;table&gt;
&lt;thead&gt;
&lt;tr&gt;
&lt;th&gt;Field&lt;/th&gt;
&lt;th&gt;What it holds&lt;/th&gt;
&lt;th&gt;Why it matters&lt;/th&gt;
&lt;/tr&gt;
&lt;/thead&gt;
&lt;tbody&gt;
&lt;tr&gt;
&lt;td&gt;decision&lt;/td&gt;
&lt;td&gt;allow or deny&lt;/td&gt;
&lt;td&gt;The outcome&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;action&lt;/td&gt;
&lt;td&gt;&lt;code&gt;clauses:approve&lt;/code&gt;&lt;/td&gt;
&lt;td&gt;What was attempted&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;reason&lt;/td&gt;
&lt;td&gt;&lt;code&gt;insufficient_scope&lt;/code&gt;&lt;/td&gt;
&lt;td&gt;Why it was refused&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;ceiling&lt;/td&gt;
&lt;td&gt;&lt;code&gt;contracts:read&lt;/code&gt;&lt;/td&gt;
&lt;td&gt;The user's permissions at that moment&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;correlationId&lt;/td&gt;
&lt;td&gt;a unique identifier&lt;/td&gt;
&lt;td&gt;Ties the row to the request&lt;/td&gt;
&lt;/tr&gt;
&lt;/tbody&gt;
&lt;/table&gt;&lt;/div&gt;

&lt;p&gt;The &lt;code&gt;correlationId&lt;/code&gt; is the part to get right. The same value appears in the denial your API returned and in the audit row it wrote. Someone reviewing an incident can hold both in one hand: here is the response the caller saw, and here is the decision that produced it.&lt;/p&gt;

&lt;p&gt;That is the difference between a log and a record. A log says something happened. A record answers &lt;em&gt;who authorized this, under whose permissions, and when&lt;/em&gt;.&lt;/p&gt;

&lt;p&gt;Enterprises buying agent products ask this question early, and they do not accept a demo as the answer. Write the row for every decision, allow and deny, and the question is already answered.&lt;/p&gt;

&lt;p&gt;&lt;a href="https://media2.dev.to/dynamic/image/width=800%2Cheight=%2Cfit=scale-down%2Cgravity=auto%2Cformat=auto/https%3A%2F%2Fdev-to-uploads.s3.us-east-2.amazonaws.com%2Fuploads%2Farticles%2F4gjchtru7y8r3sq73gy6.png" class="article-body-image-wrapper"&gt;&lt;img src="https://media2.dev.to/dynamic/image/width=800%2Cheight=%2Cfit=scale-down%2Cgravity=auto%2Cformat=auto/https%3A%2F%2Fdev-to-uploads.s3.us-east-2.amazonaws.com%2Fuploads%2Farticles%2F4gjchtru7y8r3sq73gy6.png" alt="The audit trail table in intersection mode. A deny row shows decision DENY, action clauses:approve, reason insufficient_scope, ceiling contracts:read, and a correlationId. Allow rows sit above and below it. The deny row's correlationId matches the one in the timeline." width="800" height="585"&gt;&lt;/a&gt;&lt;/p&gt;

&lt;h2&gt;
  
  
  The security details that decide whether this holds
&lt;/h2&gt;

&lt;p&gt;The check is simple. These details keep it honest under pressure.&lt;/p&gt;

&lt;h3&gt;
  
  
  The server decides how strict the check is
&lt;/h3&gt;

&lt;p&gt;Never let the caller choose its own enforcement. If an agent can send a flag that relaxes the check, the check is decoration.&lt;/p&gt;

&lt;p&gt;In the demo the mode is a server-side setting. The agent's request cannot select it. The endpoint reads the mode from the deployment, records it on the run, and ignores anything the client sends about it.&lt;/p&gt;

&lt;h3&gt;
  
  
  Fail closed
&lt;/h3&gt;

&lt;p&gt;Missing configuration must deny, not allow. If the component starts without the settings it needs to verify a token, it refuses to run. A system that opens up when it is misconfigured will eventually be misconfigured in production.&lt;/p&gt;

&lt;h3&gt;
  
  
  Check the audience
&lt;/h3&gt;

&lt;p&gt;A token issued for another API in the same tenant must not work on yours. Without an audience check, any valid token from your provider can be replayed against your endpoints. With one, a token minted for a different service is refused.&lt;/p&gt;

&lt;h3&gt;
  
  
  Keep administrative functions internal
&lt;/h3&gt;

&lt;p&gt;Registering an agent, issuing a ceiling, revoking access, changing policy: these are not user actions. They must not sit on a public surface, whatever the caller's permissions. In the demo they are internal-only functions, reachable by the server and the admin tooling, never by a request from outside.&lt;/p&gt;

&lt;h3&gt;
  
  
  Expose one authorization surface
&lt;/h3&gt;

&lt;p&gt;Give your application one function to call: &lt;code&gt;authorize()&lt;/code&gt;. Do not expose the raw permission lookup underneath it.&lt;/p&gt;

&lt;p&gt;The reason is human, not technical. If a developer can call the lower-level check, one of them eventually will, in a hurry, without the caller binding or the audit row. One public surface means every path through your code takes the same path through your rules.&lt;/p&gt;

&lt;h2&gt;
  
  
  Building it in practice
&lt;/h2&gt;

&lt;p&gt;Here is one working shape. Your stack will differ. The five parts will not.&lt;/p&gt;

&lt;p&gt;&lt;strong&gt;The application.&lt;/strong&gt; A web app with a backend that owns the protected actions. In the demo this is Next.js with Convex. The authorization component, &lt;code&gt;kinde-convex-agent-auth&lt;/code&gt;, is mounted inside the backend, so the check runs next to the data it protects.&lt;/p&gt;

&lt;p&gt;&lt;strong&gt;The agent.&lt;/strong&gt; A Python service built with CrewAI. Three agents work in order: a Clause Extractor reads the document, a Risk Flagger rates each clause, and a Sign-off Agent approves the ones that pass. The crew authenticates with its own machine credentials and calls the app over HTTP. It never touches the database and never calls the authorization component itself.&lt;/p&gt;

&lt;p&gt;That separation is deliberate. The agent asks. The application decides.&lt;/p&gt;

&lt;p&gt;The demo also ships a deterministic version of the same review that needs no model key. It drives the same endpoints and streams the same events, and its sign-off step attempts to approve every clause. Every screenshot and every number in this article comes from that deterministic run, so the results are exact and repeatable.&lt;/p&gt;

&lt;p&gt;&lt;strong&gt;The context store.&lt;/strong&gt; The app embeds clause text into Weaviate, with one tenant per organization, so a search for one customer's clauses cannot return another customer's. Vector storage needs tenancy too. An agent that can retrieve across tenants is its own kind of leak.&lt;/p&gt;

&lt;p&gt;&lt;strong&gt;The identity provider.&lt;/strong&gt; &lt;a href="https://kinde.com/?utm_source=devto&amp;amp;utm_medium=content&amp;amp;utm_campaign=shola&amp;amp;campaignid=chatgptapp&amp;amp;network=&amp;amp;adgroup=&amp;amp;keyword=&amp;amp;matchtype=&amp;amp;creative=18&amp;amp;device=&amp;amp;adposition=" rel="noopener noreferrer"&gt;Kinde&lt;/a&gt; holds the human users, their roles, and their permissions. It also holds the two machine identities: the crew's, and the management identity the server uses to read a user's permissions.&lt;/p&gt;

&lt;p&gt;&lt;strong&gt;The model.&lt;/strong&gt; The crew runs against a configurable model. Keep the model name in configuration, not in your source. Models change names and versions often, and nothing in this design depends on which one you use.&lt;/p&gt;

&lt;p&gt;&lt;a href="https://media2.dev.to/dynamic/image/width=800%2Cheight=%2Cfit=scale-down%2Cgravity=auto%2Cformat=auto/https%3A%2F%2Fdev-to-uploads.s3.us-east-2.amazonaws.com%2Fuploads%2Farticles%2F8nh9q8ngmf81xnh0mvum.png" class="article-body-image-wrapper"&gt;&lt;img src="https://media2.dev.to/dynamic/image/width=800%2Cheight=%2Cfit=scale-down%2Cgravity=auto%2Cformat=auto/https%3A%2F%2Fdev-to-uploads.s3.us-east-2.amazonaws.com%2Fuploads%2Farticles%2F8nh9q8ngmf81xnh0mvum.png" alt="The intern user with a single permission,  raw `contracts:read` endraw , in the demo organization." width="800" height="461"&gt;&lt;/a&gt;&lt;/p&gt;

&lt;h3&gt;
  
  
  The two modes
&lt;/h3&gt;

&lt;p&gt;The demo carries both behaviors, switchable on the server:&lt;/p&gt;

&lt;ul&gt;
&lt;li&gt;
&lt;strong&gt;Broken.&lt;/strong&gt; The endpoint authorizes on the agent's identity alone. This is the confused deputy, kept as a real, working code path.&lt;/li&gt;
&lt;li&gt;
&lt;strong&gt;Intersection.&lt;/strong&gt; The endpoint calls &lt;code&gt;authorize()&lt;/code&gt; and enforces user ∩ agent ∩ token.&lt;/li&gt;
&lt;/ul&gt;

&lt;p&gt;Keeping the broken path is useful, and it is honest. The failure is not a story about what might happen. It is a code path you can run.&lt;/p&gt;

&lt;h3&gt;
  
  
  The code that matters
&lt;/h3&gt;

&lt;p&gt;Three shapes carry the whole design.&lt;/p&gt;

&lt;p&gt;&lt;strong&gt;Mount the component inside your backend&lt;/strong&gt;, so the check lives with the data. The component declares the provider settings it needs, so the app declares them too and threads them through on mount:&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight typescript"&gt;&lt;code&gt;&lt;span class="kd"&gt;const&lt;/span&gt; &lt;span class="nx"&gt;app&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="nf"&gt;defineApp&lt;/span&gt;&lt;span class="p"&gt;({&lt;/span&gt;
  &lt;span class="na"&gt;env&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="p"&gt;{&lt;/span&gt;
    &lt;span class="na"&gt;KINDE_DOMAIN&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="nx"&gt;v&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nf"&gt;string&lt;/span&gt;&lt;span class="p"&gt;(),&lt;/span&gt;
    &lt;span class="na"&gt;KINDE_AUDIENCE&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="nx"&gt;v&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nf"&gt;optional&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="nx"&gt;v&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nf"&gt;string&lt;/span&gt;&lt;span class="p"&gt;()),&lt;/span&gt;
    &lt;span class="na"&gt;DELEGATION_SIGNING_SECRET&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="nx"&gt;v&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nf"&gt;string&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="nx"&gt;app&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nf"&gt;use&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="nx"&gt;agentAuth&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="p"&gt;{&lt;/span&gt;
  &lt;span class="na"&gt;env&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="p"&gt;{&lt;/span&gt;
    &lt;span class="na"&gt;KINDE_DOMAIN&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="nx"&gt;app&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nx"&gt;env&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nx"&gt;KINDE_DOMAIN&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt;
    &lt;span class="na"&gt;KINDE_AUDIENCE&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="nx"&gt;app&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nx"&gt;env&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nx"&gt;KINDE_AUDIENCE&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt;
    &lt;span class="na"&gt;DELEGATION_SIGNING_SECRET&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="nx"&gt;app&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nx"&gt;env&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nx"&gt;DELEGATION_SIGNING_SECRET&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;&lt;strong&gt;Carry the acting user on every agent request.&lt;/strong&gt; The agent sends its own token, and the identifier of the person it acts for:&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;headers&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="p"&gt;{&lt;/span&gt;
    &lt;span class="sh"&gt;"&lt;/span&gt;&lt;span class="s"&gt;Authorization&lt;/span&gt;&lt;span class="sh"&gt;"&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="s"&gt;Bearer &lt;/span&gt;&lt;span class="si"&gt;{&lt;/span&gt;&lt;span class="n"&gt;crew_token&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="sh"&gt;"&lt;/span&gt;&lt;span class="s"&gt;X-Acting-Subject&lt;/span&gt;&lt;span class="sh"&gt;"&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="n"&gt;acting_user_id&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;&lt;strong&gt;Check at the action boundary.&lt;/strong&gt; This is the line that closes the hole:&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight typescript"&gt;&lt;code&gt;&lt;span class="kd"&gt;const&lt;/span&gt; &lt;span class="p"&gt;{&lt;/span&gt; &lt;span class="nx"&gt;decision&lt;/span&gt; &lt;span class="p"&gt;}&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="k"&gt;await&lt;/span&gt; &lt;span class="nx"&gt;agentAuth&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nf"&gt;authorize&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="nx"&gt;ctx&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="nx"&gt;token&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="p"&gt;{&lt;/span&gt;
  &lt;span class="nx"&gt;instanceId&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt;
  &lt;span class="na"&gt;action&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="dl"&gt;'&lt;/span&gt;&lt;span class="s1"&gt;clauses:approve&lt;/span&gt;&lt;span class="dl"&gt;'&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt;
  &lt;span class="na"&gt;enforceTokenScopes&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="kc"&gt;true&lt;/span&gt;
&lt;span class="p"&gt;});&lt;/span&gt;

&lt;span class="k"&gt;if &lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="o"&gt;!&lt;/span&gt;&lt;span class="nx"&gt;decision&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nx"&gt;allowed&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="nf"&gt;json&lt;/span&gt;&lt;span class="p"&gt;({&lt;/span&gt;
    &lt;span class="na"&gt;error&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="dl"&gt;'&lt;/span&gt;&lt;span class="s1"&gt;authorization_denied&lt;/span&gt;&lt;span class="dl"&gt;'&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt;
    &lt;span class="na"&gt;reason&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="nx"&gt;decision&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nx"&gt;reason&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt;
    &lt;span class="na"&gt;requiredScopes&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="nx"&gt;decision&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nx"&gt;requiredScopes&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt;
    &lt;span class="na"&gt;correlationId&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="nx"&gt;decision&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nx"&gt;correlationId&lt;/span&gt;
  &lt;span class="p"&gt;},&lt;/span&gt; &lt;span class="mi"&gt;403&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;Note what is absent. There is no list of roles in the endpoint. There is no &lt;code&gt;if (user.isAdmin)&lt;/code&gt;. The endpoint states the action it is about to take and asks one question. The rules live with your permissions, not scattered through your handlers.&lt;/p&gt;

&lt;p&gt;&lt;a href="https://media2.dev.to/dynamic/image/width=800%2Cheight=%2Cfit=scale-down%2Cgravity=auto%2Cformat=auto/https%3A%2F%2Fdev-to-uploads.s3.us-east-2.amazonaws.com%2Fuploads%2Farticles%2F0haf6ngcldms99wnektv.png" class="article-body-image-wrapper"&gt;&lt;img src="https://media2.dev.to/dynamic/image/width=800%2Cheight=%2Cfit=scale-down%2Cgravity=auto%2Cformat=auto/https%3A%2F%2Fdev-to-uploads.s3.us-east-2.amazonaws.com%2Fuploads%2Farticles%2F0haf6ngcldms99wnektv.png" alt="One approval, end to end: the crew sends its token plus the acting user to your&lt;br&gt;
  endpoint. The server verifies the caller, resolves the user's ceiling from Kinde,&lt;br&gt;
  then calls authorize() for user ∩ agent ∩ token. The decision branches to allow&lt;br&gt;
  (action runs) or deny (403 with reason and correlationId). Both write an audit&lt;br&gt;
  row." width="800" height="1288"&gt;&lt;/a&gt;&lt;/p&gt;

&lt;p&gt;&lt;em&gt;One decision point, two outcomes, always a record.&lt;/em&gt;&lt;/p&gt;

&lt;h2&gt;
  
  
  Broken and fixed, side by side
&lt;/h2&gt;

&lt;p&gt;The same contract, the same agent, the same three users. Only the enforcement rule changes.&lt;/p&gt;

&lt;h3&gt;
  
  
  Broken mode
&lt;/h3&gt;

&lt;div class="table-wrapper-paragraph"&gt;&lt;table&gt;
&lt;thead&gt;
&lt;tr&gt;
&lt;th&gt;Acting user&lt;/th&gt;
&lt;th&gt;Approvals attempted&lt;/th&gt;
&lt;th&gt;Approved&lt;/th&gt;
&lt;th&gt;Blocked&lt;/th&gt;
&lt;th&gt;Privilege escalations&lt;/th&gt;
&lt;/tr&gt;
&lt;/thead&gt;
&lt;tbody&gt;
&lt;tr&gt;
&lt;td&gt;Intern (read)&lt;/td&gt;
&lt;td&gt;14&lt;/td&gt;
&lt;td&gt;14&lt;/td&gt;
&lt;td&gt;0&lt;/td&gt;
&lt;td&gt;&lt;strong&gt;14&lt;/strong&gt;&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;Analyst (read, flag)&lt;/td&gt;
&lt;td&gt;14&lt;/td&gt;
&lt;td&gt;14&lt;/td&gt;
&lt;td&gt;0&lt;/td&gt;
&lt;td&gt;&lt;strong&gt;14&lt;/strong&gt;&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;Admin (read, flag, approve)&lt;/td&gt;
&lt;td&gt;14&lt;/td&gt;
&lt;td&gt;14&lt;/td&gt;
&lt;td&gt;0&lt;/td&gt;
&lt;td&gt;0&lt;/td&gt;
&lt;/tr&gt;
&lt;/tbody&gt;
&lt;/table&gt;&lt;/div&gt;

&lt;p&gt;Every user gets the same result, because the user was never part of the decision. The intern and the analyst each escalate 14 times. Two of those approvals cover critical clauses: uncapped liability, and a class-action waiver.&lt;/p&gt;

&lt;p&gt;The admin's row is worth a second look. The outcome is correct, and it is correct by accident. Broken mode did not check the admin's permissions either. It allowed the approval because the &lt;em&gt;agent&lt;/em&gt; could approve. If that admin lost the approve permission this morning, broken mode would still approve.&lt;/p&gt;

&lt;h3&gt;
  
  
  Intersection mode
&lt;/h3&gt;

&lt;div class="table-wrapper-paragraph"&gt;&lt;table&gt;
&lt;thead&gt;
&lt;tr&gt;
&lt;th&gt;Acting user&lt;/th&gt;
&lt;th&gt;Approvals attempted&lt;/th&gt;
&lt;th&gt;Approved&lt;/th&gt;
&lt;th&gt;Blocked&lt;/th&gt;
&lt;th&gt;Privilege escalations&lt;/th&gt;
&lt;/tr&gt;
&lt;/thead&gt;
&lt;tbody&gt;
&lt;tr&gt;
&lt;td&gt;Intern (read)&lt;/td&gt;
&lt;td&gt;14&lt;/td&gt;
&lt;td&gt;0&lt;/td&gt;
&lt;td&gt;&lt;strong&gt;14&lt;/strong&gt;&lt;/td&gt;
&lt;td&gt;0&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;Analyst (read, flag)&lt;/td&gt;
&lt;td&gt;14&lt;/td&gt;
&lt;td&gt;0&lt;/td&gt;
&lt;td&gt;&lt;strong&gt;14&lt;/strong&gt;&lt;/td&gt;
&lt;td&gt;0&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;Admin (read, flag, approve)&lt;/td&gt;
&lt;td&gt;14&lt;/td&gt;
&lt;td&gt;&lt;strong&gt;14&lt;/strong&gt;&lt;/td&gt;
&lt;td&gt;0&lt;/td&gt;
&lt;td&gt;0&lt;/td&gt;
&lt;/tr&gt;
&lt;/tbody&gt;
&lt;/table&gt;&lt;/div&gt;

&lt;p&gt;Three users, three different outcomes, from one agent.&lt;/p&gt;

&lt;p&gt;The intern is refused every approval. So is the analyst, who may flag but not approve, and whose flags still go through. The admin's approvals land, and now they land &lt;em&gt;because the admin holds the permission&lt;/em&gt;, checked at the moment of the action.&lt;/p&gt;

&lt;p&gt;Each denial carries the same three facts: reason &lt;code&gt;insufficient_scope&lt;/code&gt;, the missing permission &lt;code&gt;clauses:approve&lt;/code&gt;, and a &lt;code&gt;correlationId&lt;/code&gt; that matches its audit row.&lt;/p&gt;

&lt;p&gt;The receipts echo the finding. The demo sorts the clause table by risk, so the critical rows sit at the top. In broken mode, each one shows APPROVED, decided by the intern's own identifier.&lt;/p&gt;

&lt;p&gt;&lt;a href="https://media2.dev.to/dynamic/image/width=800%2Cheight=%2Cfit=scale-down%2Cgravity=auto%2Cformat=auto/https%3A%2F%2Fdev-to-uploads.s3.us-east-2.amazonaws.com%2Fuploads%2Farticles%2F4zok8clfiff90bdydndu.png" class="article-body-image-wrapper"&gt;&lt;img src="https://media2.dev.to/dynamic/image/width=800%2Cheight=%2Cfit=scale-down%2Cgravity=auto%2Cformat=auto/https%3A%2F%2Fdev-to-uploads.s3.us-east-2.amazonaws.com%2Fuploads%2Farticles%2F4zok8clfiff90bdydndu.png" alt="The clauses table, sorted with critical rows first. Clause 3 (uncapped liability) and clause 9 (class-action waiver) show a red CRITICAL tag and an APPROVED status. The " width="800" height="646"&gt;&lt;/a&gt;&lt;/p&gt;

&lt;h2&gt;
  
  
  How this maps to your app
&lt;/h2&gt;

&lt;p&gt;Your stack is probably not this stack. That does not matter. Three things carry over to any system where an agent acts for a person.&lt;/p&gt;

&lt;h3&gt;
  
  
  1. Check at the action, not at the door
&lt;/h3&gt;

&lt;p&gt;Find the places in your code where an agent causes something to happen: an approval, a payment, a delete, a write to another system. Those are your action boundaries. Put the authorization check there.&lt;/p&gt;

&lt;p&gt;A useful test: if the only permission check in a request path happens before the agent gets involved, you have a confused deputy waiting to be found.&lt;/p&gt;

&lt;h3&gt;
  
  
  2. Enforce user ∩ agent
&lt;/h3&gt;

&lt;p&gt;Give the agent its own identity, then hold it to the smaller of two ceilings: its own, and the acting user's.&lt;/p&gt;

&lt;p&gt;This means every agent request must name the person it acts for, and that name must come from your server, not from the client. If the caller can choose whose authority it borrows, you have built a different vulnerability.&lt;/p&gt;

&lt;h3&gt;
  
  
  3. Keep the receipt
&lt;/h3&gt;

&lt;p&gt;Write one record per decision, allow and deny, with the action, the reason, the ceiling that applied, and an identifier shared with the response.&lt;/p&gt;

&lt;p&gt;You need it three times: when a customer asks why something failed, when a security reviewer asks what your agents can do, and when you ship agents to a regulated buyer who will not take your word for it.&lt;/p&gt;

&lt;p&gt;None of the three name a vendor. &lt;a href="https://kinde.com/?utm_source=devto&amp;amp;utm_medium=content&amp;amp;utm_campaign=shola&amp;amp;campaignid=chatgptapp&amp;amp;network=&amp;amp;adgroup=&amp;amp;keyword=&amp;amp;matchtype=&amp;amp;creative=18&amp;amp;device=&amp;amp;adposition=" rel="noopener noreferrer"&gt;Kinde&lt;/a&gt; makes them straightforward, because the ceiling, the machine identities, and the audit trail already exist there. The pattern is the point. Build it with what you have.&lt;/p&gt;

&lt;h2&gt;
  
  
  What this is really about
&lt;/h2&gt;

&lt;p&gt;Agent products are moving from answering to acting. An assistant that drafts an email is a convenience. An agent that sends the email, approves the invoice, or signs off the clause is a deputy with authority.&lt;/p&gt;

&lt;p&gt;Once agents act, delegation becomes the security question of the product. Not "is this agent authenticated?", but "on whose behalf, and within whose limits?".&lt;/p&gt;

&lt;p&gt;That question gets harder from here. Agents call other agents. A crew hands work down a chain. Each hand-off is a chance for authority to grow when it should only ever shrink. Intersection at the action boundary is the foundation that makes the harder cases work, because it fixes the property you need at every hop: the deputy never exceeds the person it acts for.&lt;/p&gt;

&lt;p&gt;The fix itself stays small. Name the user on the request. Ask one question where the agent acts. Keep the answer.&lt;/p&gt;

&lt;p&gt;&lt;strong&gt;An agent must never exceed the permissions of the person it acts for.&lt;/strong&gt;&lt;/p&gt;

&lt;p&gt;An intern with read-only access, working through the most capable agent you own, should not be able to approve a contract. In broken mode they approved fourteen clauses, including two that no reviewer should wave through. In intersection mode they approved none, and the record shows exactly why.&lt;/p&gt;

&lt;p&gt;Same agent. Same contract. Same user. One rule.&lt;/p&gt;

&lt;h2&gt;
  
  
  Try it and read the code
&lt;/h2&gt;

&lt;ul&gt;
&lt;li&gt;The live demo: &lt;a href="https://kinde-confused-deputy.vercel.app" rel="noopener noreferrer"&gt;kinde-confused-deputy.vercel.app&lt;/a&gt;
&lt;/li&gt;
&lt;li&gt;The source: &lt;a href="https://github.com/kinde-starter-kits/contract-intelligence-demo" rel="noopener noreferrer"&gt;github.com/kinde-starter-kits/contract-intelligence-demo&lt;/a&gt;
&lt;/li&gt;
&lt;li&gt;The authorization component: &lt;a href="https://github.com/kinde-oss/kinde-convex-agent-auth" rel="noopener noreferrer"&gt;kinde-convex-agent-auth&lt;/a&gt;
&lt;/li&gt;
&lt;/ul&gt;

&lt;p&gt;Pick a role, choose broken or intersection, and run the review. The timeline shows every step, and the records show every decision.&lt;/p&gt;

</description>
      <category>ai</category>
      <category>kinde</category>
      <category>agents</category>
      <category>tutorial</category>
    </item>
    <item>
      <title>Kinde Is Missing from Mastra's Auth Lineup, So I Built the Provider</title>
      <dc:creator>Shola Jegede</dc:creator>
      <pubDate>Sun, 21 Jun 2026 21:10:11 +0000</pubDate>
      <link>https://dev.to/sholajegede/kinde-is-missing-from-mastras-auth-lineup-so-i-built-the-provider-5gc9</link>
      <guid>https://dev.to/sholajegede/kinde-is-missing-from-mastras-auth-lineup-so-i-built-the-provider-5gc9</guid>
      <description>&lt;p&gt;&lt;em&gt;If you're building a SaaS AI agent product and you're already on Kinde, you already know the problem.&lt;/em&gt;&lt;/p&gt;

&lt;p&gt;Mastra is the TypeScript-first AI agent framework. It ships with official auth providers for Clerk, Auth0, Supabase, Firebase, WorkOS, and Better Auth. Kinde is not on that list.&lt;/p&gt;

&lt;p&gt;The obvious question is why not reach for one of those providers, since Auth0 is already there.&lt;/p&gt;

&lt;p&gt;Most developers who choose Kinde rely on far more than its login. Kinde ships with the organizational structures, permission systems, and monetization tools that products actually need, bringing auth, billing, feature flags, and multi-tenancy together in one platform. If you're building a B2B SaaS product on Kinde, you're using Kinde orgs to segment your customers, Kinde billing to manage subscriptions, and Kinde feature flags to gate features by plan. Switching to Auth0 or Clerk to support a Mastra agent would mean rebuilding all of that elsewhere, which is not a real option.&lt;/p&gt;

&lt;p&gt;That gap is the problem. You need Kinde to work with Mastra, and until now there was no clean way to connect them.&lt;/p&gt;

&lt;p&gt;That's why I built &lt;code&gt;mastra-auth-kinde&lt;/code&gt;.&lt;/p&gt;

&lt;h2&gt;
  
  
  What Mastra's auth system actually does
&lt;/h2&gt;

&lt;p&gt;When you add an auth provider to Mastra, it protects two things at once: all your API routes (&lt;code&gt;/api/agents/*&lt;/code&gt;, &lt;code&gt;/api/workflows/*&lt;/code&gt;, and so on) and your Mastra Studio UI. Every request to a protected route goes through your provider before it reaches anything else.&lt;/p&gt;

&lt;p&gt;You extend Mastra's &lt;code&gt;MastraAuthProvider&lt;/code&gt; base class and implement two methods:&lt;/p&gt;

&lt;ul&gt;
&lt;li&gt;
&lt;code&gt;authenticateToken(token, request)&lt;/code&gt; verifies the JWT and returns the decoded user, or &lt;code&gt;null&lt;/code&gt; if it fails&lt;/li&gt;
&lt;li&gt;
&lt;code&gt;authorizeUser(user, request)&lt;/code&gt; returns &lt;code&gt;true&lt;/code&gt; to let the request through, or &lt;code&gt;false&lt;/code&gt; for a 403&lt;/li&gt;
&lt;/ul&gt;

&lt;p&gt;Mastra handles everything else: extracting the Bearer token from the Authorization header, calling your methods in order, and storing the verified user in the request context so your agents and tools can access it.&lt;/p&gt;

&lt;h2&gt;
  
  
  Why Kinde specifically
&lt;/h2&gt;

&lt;p&gt;Beyond the billing and org story above, a few things make Kinde the right fit for agent developers in particular.&lt;/p&gt;

&lt;p&gt;First, Kinde's org model maps directly to multi-tenancy, so each customer gets isolated data and configuration without you building it. Each Kinde organization is a tenant, and the &lt;code&gt;org_code&lt;/code&gt; claim on every token tells you exactly which org the user belongs to. For a multi-tenant agent, one that serves different customers on the same infrastructure, this is exactly what you need.&lt;/p&gt;

&lt;p&gt;Second, machine-to-machine authentication comes standard rather than as an expensive add-on. AI agents frequently need to run background jobs, scheduled workflows, and nightly pipelines with no human user in the loop. Kinde handles this natively with client credentials tokens, and this provider handles those too, which I cover below.&lt;/p&gt;

&lt;p&gt;Third, the free tier is usable for real products, including 10,500 monthly active users, unlimited organizations, and all authentication methods including SSO. You can build a real multi-tenant agent product without paying anything until you're at scale.&lt;/p&gt;

&lt;h2&gt;
  
  
  Installation
&lt;/h2&gt;



&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight shell"&gt;&lt;code&gt;npm &lt;span class="nb"&gt;install &lt;/span&gt;github:kinde-oss/mastra-auth-kinde
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;You also need &lt;code&gt;@mastra/core&lt;/code&gt; if you don't already have it:&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight shell"&gt;&lt;code&gt;npm &lt;span class="nb"&gt;install&lt;/span&gt; @mastra/core
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;h2&gt;
  
  
  Step 1 — Basic setup
&lt;/h2&gt;

&lt;p&gt;Wire the provider into your Mastra instance:&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight typescript"&gt;&lt;code&gt;&lt;span class="k"&gt;import&lt;/span&gt; &lt;span class="p"&gt;{&lt;/span&gt; &lt;span class="nx"&gt;Mastra&lt;/span&gt; &lt;span class="p"&gt;}&lt;/span&gt; &lt;span class="k"&gt;from&lt;/span&gt; &lt;span class="dl"&gt;'&lt;/span&gt;&lt;span class="s1"&gt;@mastra/core&lt;/span&gt;&lt;span class="dl"&gt;'&lt;/span&gt;
&lt;span class="k"&gt;import&lt;/span&gt; &lt;span class="p"&gt;{&lt;/span&gt; &lt;span class="nx"&gt;MastraAuthKinde&lt;/span&gt; &lt;span class="p"&gt;}&lt;/span&gt; &lt;span class="k"&gt;from&lt;/span&gt; &lt;span class="dl"&gt;'&lt;/span&gt;&lt;span class="s1"&gt;mastra-auth-kinde&lt;/span&gt;&lt;span class="dl"&gt;'&lt;/span&gt;

&lt;span class="k"&gt;export&lt;/span&gt; &lt;span class="kd"&gt;const&lt;/span&gt; &lt;span class="nx"&gt;mastra&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="k"&gt;new&lt;/span&gt; &lt;span class="nc"&gt;Mastra&lt;/span&gt;&lt;span class="p"&gt;({&lt;/span&gt;
  &lt;span class="na"&gt;server&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="p"&gt;{&lt;/span&gt;
    &lt;span class="na"&gt;auth&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="k"&gt;new&lt;/span&gt; &lt;span class="nc"&gt;MastraAuthKinde&lt;/span&gt;&lt;span class="p"&gt;({&lt;/span&gt;
      &lt;span class="na"&gt;domain&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="dl"&gt;'&lt;/span&gt;&lt;span class="s1"&gt;https://yourapp.kinde.com&lt;/span&gt;&lt;span class="dl"&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;span class="p"&gt;})&lt;/span&gt;
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;Or use environment variables:&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="nv"&gt;KINDE_DOMAIN&lt;/span&gt;&lt;span class="o"&gt;=&lt;/span&gt;https://yourapp.kinde.com
&lt;span class="nv"&gt;KINDE_AUDIENCE&lt;/span&gt;&lt;span class="o"&gt;=&lt;/span&gt;https://api.yourapp.com  &lt;span class="c"&gt;# optional — see the audience section below&lt;/span&gt;
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;





&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight typescript"&gt;&lt;code&gt;&lt;span class="k"&gt;export&lt;/span&gt; &lt;span class="kd"&gt;const&lt;/span&gt; &lt;span class="nx"&gt;mastra&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="k"&gt;new&lt;/span&gt; &lt;span class="nc"&gt;Mastra&lt;/span&gt;&lt;span class="p"&gt;({&lt;/span&gt;
  &lt;span class="na"&gt;server&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="p"&gt;{&lt;/span&gt;
    &lt;span class="na"&gt;auth&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="k"&gt;new&lt;/span&gt; &lt;span class="nc"&gt;MastraAuthKinde&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 this is in place, every request to &lt;code&gt;/api/*&lt;/code&gt; needs a valid Kinde access token in the &lt;code&gt;Authorization: Bearer &amp;lt;token&amp;gt;&lt;/code&gt; header. Unauthenticated requests get a 401, and requests that fail the authorization check get a 403.&lt;/p&gt;

&lt;h2&gt;
  
  
  Step 2 — User token authentication
&lt;/h2&gt;

&lt;p&gt;A standard Kinde user access token carries these claims:&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight plaintext"&gt;&lt;code&gt;iss, sub, aud, azp, exp, iat, jti, scp
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;The provider verifies the token against Kinde's JWKS endpoint, validates the issuer and expiry, and checks the audience claim if you have the option configured.&lt;/p&gt;

&lt;p&gt;&lt;a href="https://kinde.com/?utm_source=devto&amp;amp;utm_medium=content&amp;amp;utm_campaign=shola&amp;amp;campaignid=chatgptapp&amp;amp;network=&amp;amp;adgroup=&amp;amp;keyword=&amp;amp;matchtype=&amp;amp;creative=16&amp;amp;device=&amp;amp;adposition=" rel="noopener noreferrer"&gt;Kinde's&lt;/a&gt; JWKS endpoint sits at &lt;code&gt;/.well-known/jwks&lt;/code&gt; with no &lt;code&gt;.json&lt;/code&gt; extension, even though the common assumption is &lt;code&gt;/.well-known/jwks.json&lt;/code&gt;. Verify it against your tenant's discovery document before assuming:&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight shell"&gt;&lt;code&gt;curl &lt;span class="nt"&gt;-s&lt;/span&gt; https://yourapp.kinde.com/.well-known/openid-configuration | jq .jwks_uri
&lt;span class="c"&gt;# "https://yourapp.kinde.com/.well-known/jwks"&lt;/span&gt;
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;The provider handles this correctly out of the box.&lt;/p&gt;

&lt;h3&gt;
  
  
  The audience option
&lt;/h3&gt;

&lt;p&gt;Only set &lt;code&gt;audience&lt;/code&gt; (or &lt;code&gt;KINDE_AUDIENCE&lt;/code&gt;) once you've registered and bound an API audience in the Kinde dashboard. A default Kinde user token carries an empty &lt;code&gt;aud&lt;/code&gt; array, and any token with an empty &lt;code&gt;aud&lt;/code&gt; will fail the audience check if the option is set.&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight typescript"&gt;&lt;code&gt;&lt;span class="k"&gt;new&lt;/span&gt; &lt;span class="nc"&gt;MastraAuthKinde&lt;/span&gt;&lt;span class="p"&gt;({&lt;/span&gt;
  &lt;span class="na"&gt;domain&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="dl"&gt;'&lt;/span&gt;&lt;span class="s1"&gt;https://yourapp.kinde.com&lt;/span&gt;&lt;span class="dl"&gt;'&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt;
  &lt;span class="na"&gt;audience&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="dl"&gt;'&lt;/span&gt;&lt;span class="s1"&gt;https://api.yourapp.com&lt;/span&gt;&lt;span class="dl"&gt;'&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="c1"&gt;// only add this after binding an API audience in Kinde&lt;/span&gt;
&lt;span class="p"&gt;})&lt;/span&gt;
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;h2&gt;
  
  
  Step 3 — M2M / system-actor support
&lt;/h2&gt;

&lt;p&gt;In the standard agent setup, a human user logs in, the frontend gets a token, and that token is passed to the Mastra API, which works for user-facing agents. Real agent architectures also have background jobs, things like nightly workflows, scheduled pipelines, and cron tasks, running with no human attached.&lt;/p&gt;

&lt;p&gt;Kinde handles this with machine-to-machine apps using the OAuth client credentials flow. The resulting token looks different from a user token:&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;"aud"&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="s2"&gt;"https://yourapp.kinde.com/api"&lt;/span&gt;&lt;span class="p"&gt;],&lt;/span&gt;&lt;span class="w"&gt;
  &lt;/span&gt;&lt;span class="nl"&gt;"azp"&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt;&lt;span class="w"&gt; &lt;/span&gt;&lt;span class="s2"&gt;"your_client_id"&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt;&lt;span class="w"&gt;
  &lt;/span&gt;&lt;span class="nl"&gt;"exp"&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt;&lt;span class="w"&gt; &lt;/span&gt;&lt;span class="mi"&gt;1234567890&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt;&lt;span class="w"&gt;
  &lt;/span&gt;&lt;span class="nl"&gt;"gty"&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="s2"&gt;"client_credentials"&lt;/span&gt;&lt;span class="p"&gt;],&lt;/span&gt;&lt;span class="w"&gt;
  &lt;/span&gt;&lt;span class="nl"&gt;"iat"&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt;&lt;span class="w"&gt; &lt;/span&gt;&lt;span class="mi"&gt;1234567890&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt;&lt;span class="w"&gt;
  &lt;/span&gt;&lt;span class="nl"&gt;"iss"&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt;&lt;span class="w"&gt; &lt;/span&gt;&lt;span class="s2"&gt;"https://yourapp.kinde.com"&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt;&lt;span class="w"&gt;
  &lt;/span&gt;&lt;span class="nl"&gt;"org_code"&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt;&lt;span class="w"&gt; &lt;/span&gt;&lt;span class="s2"&gt;"org_abc123"&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt;&lt;span class="w"&gt;
  &lt;/span&gt;&lt;span class="nl"&gt;"scope"&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt;&lt;span class="w"&gt; &lt;/span&gt;&lt;span class="s2"&gt;"read:users"&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt;&lt;span class="w"&gt;
  &lt;/span&gt;&lt;span class="nl"&gt;"scp"&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;"v"&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt;&lt;span class="w"&gt; &lt;/span&gt;&lt;span class="s2"&gt;"2"&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;Two things to notice: there is no &lt;code&gt;sub&lt;/code&gt; claim, and there is a &lt;code&gt;gty&lt;/code&gt; claim set to &lt;code&gt;["client_credentials"]&lt;/code&gt;. That combination tells you this is a machine rather than a person.&lt;/p&gt;

&lt;p&gt;The provider detects this and treats the token as a trusted system actor instead of rejecting it. You can check for it in your agent tools or route handlers:&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight typescript"&gt;&lt;code&gt;&lt;span class="k"&gt;import&lt;/span&gt; &lt;span class="p"&gt;{&lt;/span&gt; &lt;span class="nx"&gt;isSystemActor&lt;/span&gt; &lt;span class="p"&gt;}&lt;/span&gt; &lt;span class="k"&gt;from&lt;/span&gt; &lt;span class="dl"&gt;'&lt;/span&gt;&lt;span class="s1"&gt;mastra-auth-kinde&lt;/span&gt;&lt;span class="dl"&gt;'&lt;/span&gt;

&lt;span class="kd"&gt;const&lt;/span&gt; &lt;span class="nx"&gt;user&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="nx"&gt;requestContext&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nf"&gt;get&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="dl"&gt;'&lt;/span&gt;&lt;span class="s1"&gt;user&lt;/span&gt;&lt;span class="dl"&gt;'&lt;/span&gt;&lt;span class="p"&gt;)&lt;/span&gt;

&lt;span class="k"&gt;if &lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="nf"&gt;isSystemActor&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="nx"&gt;user&lt;/span&gt;&lt;span class="p"&gt;))&lt;/span&gt; &lt;span class="p"&gt;{&lt;/span&gt;
  &lt;span class="c1"&gt;// trusted background process — no human user attached&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="dl"&gt;'&lt;/span&gt;&lt;span class="s1"&gt;running for org:&lt;/span&gt;&lt;span class="dl"&gt;'&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="nx"&gt;user&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nx"&gt;org_code&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;So a nightly workflow can mint a Kinde M2M token, pass it to your Mastra API, and authenticate cleanly, with no human user required.&lt;/p&gt;

&lt;h2&gt;
  
  
  Step 4 — Org-based access control
&lt;/h2&gt;

&lt;p&gt;If you're building a multi-tenant product where different organizations should only access their own agents and data, use &lt;code&gt;allowedOrgCodes&lt;/code&gt;:&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight typescript"&gt;&lt;code&gt;&lt;span class="k"&gt;new&lt;/span&gt; &lt;span class="nc"&gt;MastraAuthKinde&lt;/span&gt;&lt;span class="p"&gt;({&lt;/span&gt;
  &lt;span class="na"&gt;domain&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="dl"&gt;'&lt;/span&gt;&lt;span class="s1"&gt;https://yourapp.kinde.com&lt;/span&gt;&lt;span class="dl"&gt;'&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt;
  &lt;span class="na"&gt;allowedOrgCodes&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="p"&gt;[&lt;/span&gt;&lt;span class="dl"&gt;'&lt;/span&gt;&lt;span class="s1"&gt;org_abc123&lt;/span&gt;&lt;span class="dl"&gt;'&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="dl"&gt;'&lt;/span&gt;&lt;span class="s1"&gt;org_def456&lt;/span&gt;&lt;span class="dl"&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;The provider checks the &lt;code&gt;org_code&lt;/code&gt; claim on every token and rejects anything that doesn't match. This works for both user tokens (when org claims are enabled in Kinde) and M2M tokens, which carry &lt;code&gt;org_code&lt;/code&gt; automatically when the app is org-scoped in Kinde.&lt;/p&gt;

&lt;p&gt;On user tokens, &lt;code&gt;org_code&lt;/code&gt; only appears once you've enabled org claims in the Kinde dashboard token customization settings, so if you decode a default user token and the claim is missing, that is a Kinde config step rather than a bug in the provider.&lt;/p&gt;

&lt;h2&gt;
  
  
  Step 5 — Edge deployment
&lt;/h2&gt;

&lt;p&gt;The provider uses &lt;code&gt;jose&lt;/code&gt; for JWT verification, which runs on Web Crypto and &lt;code&gt;fetch&lt;/code&gt;, with no Node.js built-ins and no &lt;code&gt;nodejs_compat&lt;/code&gt; flag needed on Cloudflare Workers.&lt;/p&gt;

&lt;p&gt;This matters if you're planning to deploy your Mastra agent to the edge. Node-only JWKS libraries fail at build time on Workers with missing &lt;code&gt;crypto&lt;/code&gt;, &lt;code&gt;buffer&lt;/code&gt;, &lt;code&gt;stream&lt;/code&gt;, &lt;code&gt;http&lt;/code&gt;, &lt;code&gt;https&lt;/code&gt;, &lt;code&gt;events&lt;/code&gt;, and &lt;code&gt;util&lt;/code&gt;. With &lt;code&gt;jose&lt;/code&gt;, it builds clean and runs without any additional flags. This is the same approach Mastra's own Auth0 provider uses.&lt;/p&gt;

&lt;h2&gt;
  
  
  Putting it all together
&lt;/h2&gt;

&lt;p&gt;Here's a full setup for a multi-tenant Mastra agent that supports both human users and background M2M workflows:&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight typescript"&gt;&lt;code&gt;&lt;span class="k"&gt;import&lt;/span&gt; &lt;span class="p"&gt;{&lt;/span&gt; &lt;span class="nx"&gt;Mastra&lt;/span&gt; &lt;span class="p"&gt;}&lt;/span&gt; &lt;span class="k"&gt;from&lt;/span&gt; &lt;span class="dl"&gt;'&lt;/span&gt;&lt;span class="s1"&gt;@mastra/core&lt;/span&gt;&lt;span class="dl"&gt;'&lt;/span&gt;
&lt;span class="k"&gt;import&lt;/span&gt; &lt;span class="p"&gt;{&lt;/span&gt; &lt;span class="nx"&gt;Agent&lt;/span&gt; &lt;span class="p"&gt;}&lt;/span&gt; &lt;span class="k"&gt;from&lt;/span&gt; &lt;span class="dl"&gt;'&lt;/span&gt;&lt;span class="s1"&gt;@mastra/core/agent&lt;/span&gt;&lt;span class="dl"&gt;'&lt;/span&gt;
&lt;span class="k"&gt;import&lt;/span&gt; &lt;span class="p"&gt;{&lt;/span&gt; &lt;span class="nx"&gt;openai&lt;/span&gt; &lt;span class="p"&gt;}&lt;/span&gt; &lt;span class="k"&gt;from&lt;/span&gt; &lt;span class="dl"&gt;'&lt;/span&gt;&lt;span class="s1"&gt;@ai-sdk/openai&lt;/span&gt;&lt;span class="dl"&gt;'&lt;/span&gt;
&lt;span class="k"&gt;import&lt;/span&gt; &lt;span class="p"&gt;{&lt;/span&gt; &lt;span class="nx"&gt;MastraAuthKinde&lt;/span&gt; &lt;span class="p"&gt;}&lt;/span&gt; &lt;span class="k"&gt;from&lt;/span&gt; &lt;span class="dl"&gt;'&lt;/span&gt;&lt;span class="s1"&gt;mastra-auth-kinde&lt;/span&gt;&lt;span class="dl"&gt;'&lt;/span&gt;

&lt;span class="kd"&gt;const&lt;/span&gt; &lt;span class="nx"&gt;supportAgent&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="k"&gt;new&lt;/span&gt; &lt;span class="nc"&gt;Agent&lt;/span&gt;&lt;span class="p"&gt;({&lt;/span&gt;
  &lt;span class="na"&gt;name&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="dl"&gt;'&lt;/span&gt;&lt;span class="s1"&gt;support-agent&lt;/span&gt;&lt;span class="dl"&gt;'&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt;
  &lt;span class="na"&gt;instructions&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="dl"&gt;'&lt;/span&gt;&lt;span class="s1"&gt;You are a helpful support agent.&lt;/span&gt;&lt;span class="dl"&gt;'&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt;
  &lt;span class="na"&gt;model&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="nf"&gt;openai&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="dl"&gt;'&lt;/span&gt;&lt;span class="s1"&gt;gpt-4o-mini&lt;/span&gt;&lt;span class="dl"&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;export&lt;/span&gt; &lt;span class="kd"&gt;const&lt;/span&gt; &lt;span class="nx"&gt;mastra&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="k"&gt;new&lt;/span&gt; &lt;span class="nc"&gt;Mastra&lt;/span&gt;&lt;span class="p"&gt;({&lt;/span&gt;
  &lt;span class="na"&gt;agents&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="p"&gt;{&lt;/span&gt; &lt;span class="nx"&gt;supportAgent&lt;/span&gt; &lt;span class="p"&gt;},&lt;/span&gt;
  &lt;span class="na"&gt;server&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="p"&gt;{&lt;/span&gt;
    &lt;span class="na"&gt;auth&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="k"&gt;new&lt;/span&gt; &lt;span class="nc"&gt;MastraAuthKinde&lt;/span&gt;&lt;span class="p"&gt;({&lt;/span&gt;
      &lt;span class="na"&gt;domain&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="nx"&gt;process&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nx"&gt;env&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nx"&gt;KINDE_DOMAIN&lt;/span&gt;&lt;span class="o"&gt;!&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt;
      &lt;span class="na"&gt;audience&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="nx"&gt;process&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nx"&gt;env&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nx"&gt;KINDE_AUDIENCE&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt;
      &lt;span class="na"&gt;allowedOrgCodes&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="nx"&gt;process&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nx"&gt;env&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nx"&gt;KINDE_ALLOWED_ORGS&lt;/span&gt;&lt;span class="p"&gt;?.&lt;/span&gt;&lt;span class="nf"&gt;split&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="dl"&gt;'&lt;/span&gt;&lt;span class="s1"&gt;,&lt;/span&gt;&lt;span class="dl"&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;span class="p"&gt;})&lt;/span&gt;
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;On the frontend, get the access token from the Kinde SDK and pass it as a Bearer token:&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight typescript"&gt;&lt;code&gt;&lt;span class="k"&gt;import&lt;/span&gt; &lt;span class="p"&gt;{&lt;/span&gt; &lt;span class="nx"&gt;useKindeAuth&lt;/span&gt; &lt;span class="p"&gt;}&lt;/span&gt; &lt;span class="k"&gt;from&lt;/span&gt; &lt;span class="dl"&gt;'&lt;/span&gt;&lt;span class="s1"&gt;@kinde-oss/kinde-auth-nextjs&lt;/span&gt;&lt;span class="dl"&gt;'&lt;/span&gt;

&lt;span class="kd"&gt;const&lt;/span&gt; &lt;span class="p"&gt;{&lt;/span&gt; &lt;span class="nx"&gt;getToken&lt;/span&gt; &lt;span class="p"&gt;}&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="nf"&gt;useKindeAuth&lt;/span&gt;&lt;span class="p"&gt;()&lt;/span&gt;

&lt;span class="kd"&gt;const&lt;/span&gt; &lt;span class="nx"&gt;response&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="k"&gt;await&lt;/span&gt; &lt;span class="nf"&gt;fetch&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="dl"&gt;'&lt;/span&gt;&lt;span class="s1"&gt;/api/agents/support-agent/generate&lt;/span&gt;&lt;span class="dl"&gt;'&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="p"&gt;{&lt;/span&gt;
  &lt;span class="na"&gt;method&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="dl"&gt;'&lt;/span&gt;&lt;span class="s1"&gt;POST&lt;/span&gt;&lt;span class="dl"&gt;'&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt;
  &lt;span class="na"&gt;headers&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="p"&gt;{&lt;/span&gt;
    &lt;span class="dl"&gt;'&lt;/span&gt;&lt;span class="s1"&gt;Authorization&lt;/span&gt;&lt;span class="dl"&gt;'&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="s2"&gt;`Bearer &lt;/span&gt;&lt;span class="p"&gt;${&lt;/span&gt;&lt;span class="k"&gt;await&lt;/span&gt; &lt;span class="nf"&gt;getToken&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="dl"&gt;'&lt;/span&gt;&lt;span class="s1"&gt;Content-Type&lt;/span&gt;&lt;span class="dl"&gt;'&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="dl"&gt;'&lt;/span&gt;&lt;span class="s1"&gt;application/json&lt;/span&gt;&lt;span class="dl"&gt;'&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt;
  &lt;span class="p"&gt;},&lt;/span&gt;
  &lt;span class="na"&gt;body&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;messages&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="p"&gt;[{&lt;/span&gt; &lt;span class="na"&gt;role&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="dl"&gt;'&lt;/span&gt;&lt;span class="s1"&gt;user&lt;/span&gt;&lt;span class="dl"&gt;'&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="na"&gt;content&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="dl"&gt;'&lt;/span&gt;&lt;span class="s1"&gt;Hello&lt;/span&gt;&lt;span class="dl"&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;For background workflows, get an M2M token via the client credentials flow:&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight typescript"&gt;&lt;code&gt;&lt;span class="c1"&gt;// Server-side only — never expose client_secret in the browser&lt;/span&gt;
&lt;span class="kd"&gt;const&lt;/span&gt; &lt;span class="nx"&gt;tokenResponse&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="k"&gt;await&lt;/span&gt; &lt;span class="nf"&gt;fetch&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="nx"&gt;process&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nx"&gt;env&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nx"&gt;KINDE_DOMAIN&lt;/span&gt;&lt;span class="p"&gt;}&lt;/span&gt;&lt;span class="s2"&gt;/oauth2/token`&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt;
  &lt;span class="p"&gt;{&lt;/span&gt;
    &lt;span class="na"&gt;method&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="dl"&gt;'&lt;/span&gt;&lt;span class="s1"&gt;POST&lt;/span&gt;&lt;span class="dl"&gt;'&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt;
    &lt;span class="na"&gt;headers&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="p"&gt;{&lt;/span&gt; &lt;span class="dl"&gt;'&lt;/span&gt;&lt;span class="s1"&gt;Content-Type&lt;/span&gt;&lt;span class="dl"&gt;'&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="dl"&gt;'&lt;/span&gt;&lt;span class="s1"&gt;application/x-www-form-urlencoded&lt;/span&gt;&lt;span class="dl"&gt;'&lt;/span&gt; &lt;span class="p"&gt;},&lt;/span&gt;
    &lt;span class="na"&gt;body&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="k"&gt;new&lt;/span&gt; &lt;span class="nc"&gt;URLSearchParams&lt;/span&gt;&lt;span class="p"&gt;({&lt;/span&gt;
      &lt;span class="na"&gt;grant_type&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="dl"&gt;'&lt;/span&gt;&lt;span class="s1"&gt;client_credentials&lt;/span&gt;&lt;span class="dl"&gt;'&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt;
      &lt;span class="na"&gt;client_id&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="nx"&gt;process&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nx"&gt;env&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nx"&gt;KINDE_M2M_CLIENT_ID&lt;/span&gt;&lt;span class="o"&gt;!&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt;
      &lt;span class="na"&gt;client_secret&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="nx"&gt;process&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nx"&gt;env&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nx"&gt;KINDE_M2M_CLIENT_SECRET&lt;/span&gt;&lt;span class="o"&gt;!&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt;
      &lt;span class="na"&gt;audience&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="nx"&gt;process&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nx"&gt;env&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nx"&gt;KINDE_AUDIENCE&lt;/span&gt;&lt;span class="o"&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;span class="p"&gt;)&lt;/span&gt;

&lt;span class="kd"&gt;const&lt;/span&gt; &lt;span class="p"&gt;{&lt;/span&gt; &lt;span class="nx"&gt;access_token&lt;/span&gt; &lt;span class="p"&gt;}&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="k"&gt;await&lt;/span&gt; &lt;span class="nx"&gt;tokenResponse&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nf"&gt;json&lt;/span&gt;&lt;span class="p"&gt;()&lt;/span&gt;

&lt;span class="kd"&gt;const&lt;/span&gt; &lt;span class="nx"&gt;agentResponse&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="k"&gt;await&lt;/span&gt; &lt;span class="nf"&gt;fetch&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="dl"&gt;'&lt;/span&gt;&lt;span class="s1"&gt;/api/agents/support-agent/generate&lt;/span&gt;&lt;span class="dl"&gt;'&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="p"&gt;{&lt;/span&gt;
  &lt;span class="na"&gt;method&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="dl"&gt;'&lt;/span&gt;&lt;span class="s1"&gt;POST&lt;/span&gt;&lt;span class="dl"&gt;'&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt;
  &lt;span class="na"&gt;headers&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="p"&gt;{&lt;/span&gt;
    &lt;span class="dl"&gt;'&lt;/span&gt;&lt;span class="s1"&gt;Authorization&lt;/span&gt;&lt;span class="dl"&gt;'&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="s2"&gt;`Bearer &lt;/span&gt;&lt;span class="p"&gt;${&lt;/span&gt;&lt;span class="nx"&gt;access_token&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="dl"&gt;'&lt;/span&gt;&lt;span class="s1"&gt;Content-Type&lt;/span&gt;&lt;span class="dl"&gt;'&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="dl"&gt;'&lt;/span&gt;&lt;span class="s1"&gt;application/json&lt;/span&gt;&lt;span class="dl"&gt;'&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt;
  &lt;span class="p"&gt;},&lt;/span&gt;
  &lt;span class="na"&gt;body&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;messages&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="p"&gt;[{&lt;/span&gt; &lt;span class="na"&gt;role&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="dl"&gt;'&lt;/span&gt;&lt;span class="s1"&gt;user&lt;/span&gt;&lt;span class="dl"&gt;'&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="na"&gt;content&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="dl"&gt;'&lt;/span&gt;&lt;span class="s1"&gt;Run the nightly summary&lt;/span&gt;&lt;span class="dl"&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;Both token types work through the same provider, the same API, and the same Mastra instance.&lt;/p&gt;

&lt;h2&gt;
  
  
  What the provider exports
&lt;/h2&gt;



&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight typescript"&gt;&lt;code&gt;&lt;span class="k"&gt;import&lt;/span&gt; &lt;span class="p"&gt;{&lt;/span&gt;
  &lt;span class="nx"&gt;MastraAuthKinde&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt;  &lt;span class="c1"&gt;// The main auth provider class&lt;/span&gt;
  &lt;span class="nx"&gt;isSystemActor&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt;    &lt;span class="c1"&gt;// Returns true for M2M / client credentials tokens&lt;/span&gt;
&lt;span class="p"&gt;}&lt;/span&gt; &lt;span class="k"&gt;from&lt;/span&gt; &lt;span class="dl"&gt;'&lt;/span&gt;&lt;span class="s1"&gt;mastra-auth-kinde&lt;/span&gt;&lt;span class="dl"&gt;'&lt;/span&gt;
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;Full options:&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight typescript"&gt;&lt;code&gt;&lt;span class="k"&gt;new&lt;/span&gt; &lt;span class="nc"&gt;MastraAuthKinde&lt;/span&gt;&lt;span class="p"&gt;({&lt;/span&gt;
  &lt;span class="na"&gt;domain&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="dl"&gt;'&lt;/span&gt;&lt;span class="s1"&gt;https://yourapp.kinde.com&lt;/span&gt;&lt;span class="dl"&gt;'&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt;     &lt;span class="c1"&gt;// Required&lt;/span&gt;
  &lt;span class="na"&gt;audience&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="dl"&gt;'&lt;/span&gt;&lt;span class="s1"&gt;https://api.yourapp.com&lt;/span&gt;&lt;span class="dl"&gt;'&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt;      &lt;span class="c1"&gt;// Optional — only set after binding in Kinde&lt;/span&gt;
  &lt;span class="na"&gt;allowedOrgCodes&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="p"&gt;[&lt;/span&gt;&lt;span class="dl"&gt;'&lt;/span&gt;&lt;span class="s1"&gt;org_abc123&lt;/span&gt;&lt;span class="dl"&gt;'&lt;/span&gt;&lt;span class="p"&gt;],&lt;/span&gt;           &lt;span class="c1"&gt;// Optional — restrict to specific orgs&lt;/span&gt;
  &lt;span class="na"&gt;name&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="dl"&gt;'&lt;/span&gt;&lt;span class="s1"&gt;kinde&lt;/span&gt;&lt;span class="dl"&gt;'&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt;                             &lt;span class="c1"&gt;// Optional — overrides the provider name&lt;/span&gt;
&lt;span class="p"&gt;})&lt;/span&gt;
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;h2&gt;
  
  
  What's next
&lt;/h2&gt;

&lt;p&gt;The provider is open-source and usable today at &lt;a href="https://github.com/kinde-oss/mastra-auth-kinde" rel="noopener noreferrer"&gt;github.com/kinde-oss/mastra-auth-kinde&lt;/a&gt;.&lt;/p&gt;

&lt;p&gt;&lt;a href="https://kinde.com/?utm_source=devto&amp;amp;utm_medium=content&amp;amp;utm_campaign=shola&amp;amp;campaignid=chatgptapp&amp;amp;network=&amp;amp;adgroup=&amp;amp;keyword=&amp;amp;matchtype=&amp;amp;creative=16&amp;amp;device=&amp;amp;adposition=" rel="noopener noreferrer"&gt;Kinde is free for up to 10,500 monthly active users&lt;/a&gt;, with no credit card required to start. You can create an account and try this provider against a test organization at kinde.com.&lt;/p&gt;

&lt;p&gt;&lt;em&gt;If this saved you time, drop a reaction. And if you're building something with Mastra agents, I'd love to hear about it in the comments.&lt;/em&gt;&lt;/p&gt;

</description>
      <category>ai</category>
      <category>kinde</category>
      <category>mastra</category>
      <category>webdev</category>
    </item>
    <item>
      <title>How to Configure SAML Connections Programmatically With Kinde's Management API</title>
      <dc:creator>Shola Jegede</dc:creator>
      <pubDate>Sun, 21 Jun 2026 20:58:13 +0000</pubDate>
      <link>https://dev.to/sholajegede/how-to-configure-saml-connections-programmatically-with-kindes-management-api-10b6</link>
      <guid>https://dev.to/sholajegede/how-to-configure-saml-connections-programmatically-with-kindes-management-api-10b6</guid>
      <description>&lt;p&gt;&lt;em&gt;You have forty enterprise customers, and every one of them needs its own SAML connection. You are not going to click through a dashboard forty times, and you definitely are not going to do it again every time one of them rotates a certificate. This is how you make the connection setup part of your onboarding flow instead of a recurring support ticket.&lt;/em&gt;&lt;/p&gt;

&lt;p&gt;Configuring a single SAML connection by hand is fine. You open the Kinde dashboard, paste in a metadata URL, pick a Name ID format, save, and move on. The trouble starts when SAML stops being a one-off and becomes a repeatable step in how you onboard enterprise customers. Each customer's IT team sends slightly different details, a different Name ID format here, a stricter signing algorithm there, a binding their IdP happens to require, and every one of those becomes a manual configuration session that someone on your team has to run and then babysit.&lt;/p&gt;

&lt;p&gt;At that point the dashboard is the wrong tool, and Kinde's Management API is the right one. Instead of a human entering values into a form, your onboarding flow makes an authenticated API call, the connection comes into existence in seconds, and your team is involved only when something genuinely needs a human. This article walks through how to build that, from authenticating to the API through to a provisioning service that creates, updates, and tears down SAML connections as customers come and go.&lt;/p&gt;

&lt;p&gt;&lt;strong&gt;What you will learn&lt;/strong&gt;&lt;/p&gt;

&lt;ul&gt;
&lt;li&gt;Why manual SAML setup quietly breaks once you have more than a handful of enterprise customers&lt;/li&gt;
&lt;li&gt;How to authenticate to the Kinde Management API with a machine-to-machine application&lt;/li&gt;
&lt;li&gt;How to create a SAML connection in a single API call, and what each of the key fields controls&lt;/li&gt;
&lt;li&gt;How to map the details a customer's IT team sends you onto the right field values&lt;/li&gt;
&lt;li&gt;When to update a connection in place versus replace it entirely, and the trap that sits between those two&lt;/li&gt;
&lt;li&gt;How to use Kinde's strict validation to fail fast inside a provisioning pipeline rather than discovering a broken connection in production&lt;/li&gt;
&lt;li&gt;How to wire all of this into an onboarding service that provisions, updates, and deprovisions connections automatically&lt;/li&gt;
&lt;/ul&gt;

&lt;h2&gt;
  
  
  When clicking through the dashboard stops scaling
&lt;/h2&gt;

&lt;p&gt;The dashboard works beautifully for your first enterprise customer and remains tolerable for your second and third. The failure is gradual, which is part of why teams do not see it coming. Each new customer adds a configuration session, each session is a context switch for whoever runs it, and each connection becomes a thing that can drift, break, or need updating later. By the time you are at a couple of dozen customers, a meaningful slice of someone's week is spent inside the Kinde SAML form, and the work only grows from there.&lt;/p&gt;

&lt;p&gt;There is a second cost that is easy to miss. When connection setup lives in a human's hands, it cannot be part of your product's onboarding flow. The customer signs the contract, and then they wait for your team to schedule the SAML work. Compare that to the version where your backend provisions the connection the moment the customer's plan upgrades to enterprise, hands their IT admin the ACS URL in the welcome email, and is ready before the admin has finished reading it. The difference between those two experiences is the difference between SSO as a support burden and SSO as a feature that sells itself.&lt;/p&gt;

&lt;p&gt;The Management API is what moves you from the first world to the second. Everything below assumes that destination, a system where SAML connections are provisioned by code as a natural consequence of a customer becoming an enterprise customer.&lt;/p&gt;

&lt;p&gt;&lt;a href="https://media2.dev.to/dynamic/image/width=800%2Cheight=%2Cfit=scale-down%2Cgravity=auto%2Cformat=auto/https%3A%2F%2Fdev-to-uploads.s3.us-east-2.amazonaws.com%2Fuploads%2Farticles%2Fkuo69wied4pzku98db9q.png" class="article-body-image-wrapper"&gt;&lt;img src="https://media2.dev.to/dynamic/image/width=800%2Cheight=%2Cfit=scale-down%2Cgravity=auto%2Cformat=auto/https%3A%2F%2Fdev-to-uploads.s3.us-east-2.amazonaws.com%2Fuploads%2Farticles%2Fkuo69wied4pzku98db9q.png" alt="Top lane, labelled MANUAL: a stick-figure customer signs a contract, then a long dotted " width="796" height="75"&gt;&lt;/a&gt;&lt;/p&gt;

&lt;h2&gt;
  
  
  How the pieces fit together
&lt;/h2&gt;

&lt;p&gt;Before any code, it helps to hold the whole shape in your head, because the individual API calls make far more sense once you can see where they sit.&lt;/p&gt;

&lt;p&gt;Your application's onboarding flow is the trigger. When a customer crosses into enterprise territory, whether that is a plan upgrade, a sales handoff, or a manual flag your team flips, that event calls into a small provisioning service you own. That service authenticates to the Kinde Management API using a machine-to-machine application, creates a SAML connection scoped to the customer's Kinde organization, and returns the details the customer's IT admin needs to finish their side. Later, the same service handles changes, a certificate rotation becomes an update, a switch to a different identity provider becomes a replacement, and a customer leaving becomes a deletion.&lt;/p&gt;

&lt;p&gt;In &lt;a href="https://kinde.com/?utm_source=devto&amp;amp;utm_medium=content&amp;amp;utm_campaign=shola&amp;amp;campaignid=chatgptapp&amp;amp;network=&amp;amp;adgroup=&amp;amp;keyword=&amp;amp;matchtype=&amp;amp;creative=18&amp;amp;device=&amp;amp;adposition=" rel="noopener noreferrer"&gt;Kinde's model&lt;/a&gt;, each enterprise customer maps to a Kinde organization, and the SAML connection is attached to that organization. That mapping is what keeps tenants isolated, since one customer's Okta setup has no bearing on another's Entra configuration, and it is also the key your provisioning service uses to know which connection belongs to whom.&lt;/p&gt;

&lt;p&gt;&lt;a href="https://media2.dev.to/dynamic/image/width=800%2Cheight=%2Cfit=scale-down%2Cgravity=auto%2Cformat=auto/https%3A%2F%2Fdev-to-uploads.s3.us-east-2.amazonaws.com%2Fuploads%2Farticles%2Fmmdgyr7qt05b4jgfshfe.png" class="article-body-image-wrapper"&gt;&lt;img src="https://media2.dev.to/dynamic/image/width=800%2Cheight=%2Cfit=scale-down%2Cgravity=auto%2Cformat=auto/https%3A%2F%2Fdev-to-uploads.s3.us-east-2.amazonaws.com%2Fuploads%2Farticles%2Fmmdgyr7qt05b4jgfshfe.png" alt="Provisioning architecture, a left-to-right sequence that doubles as the article's table of contents. Box 1: " width="800" height="76"&gt;&lt;/a&gt;&lt;/p&gt;

&lt;h2&gt;
  
  
  Step 1: Authenticate to the Management API
&lt;/h2&gt;

&lt;p&gt;Every call you make is authenticated with a short-lived access token, and you get that token from a machine-to-machine application. In the Kinde dashboard you create an M2M application under Settings then Applications, choosing Machine to Machine as the type, and Kinde issues it a client ID and client secret. You then authorize that application for the Management API and grant it only the scopes it needs, which for our purposes are the connection management scopes.&lt;/p&gt;

&lt;p&gt;&lt;a href="https://media2.dev.to/dynamic/image/width=800%2Cheight=%2Cfit=scale-down%2Cgravity=auto%2Cformat=auto/https%3A%2F%2Fdev-to-uploads.s3.us-east-2.amazonaws.com%2Fuploads%2Farticles%2Fmoqw2qj6yvy26jrgincl.png" class="article-body-image-wrapper"&gt;&lt;img src="https://media2.dev.to/dynamic/image/width=800%2Cheight=%2Cfit=scale-down%2Cgravity=auto%2Cformat=auto/https%3A%2F%2Fdev-to-uploads.s3.us-east-2.amazonaws.com%2Fuploads%2Farticles%2Fmoqw2qj6yvy26jrgincl.png" alt="The Kinde " width="800" height="502"&gt;&lt;/a&gt;&lt;/p&gt;

&lt;p&gt;With the application authorized, your service exchanges its client credentials for an access token by calling the token endpoint. This is the same client credentials flow you would use for any backend-to-backend Kinde call.&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight typescript"&gt;&lt;code&gt;&lt;span class="c1"&gt;// Exchange M2M client credentials for a Management API access token.&lt;/span&gt;
&lt;span class="k"&gt;async&lt;/span&gt; &lt;span class="kd"&gt;function&lt;/span&gt; &lt;span class="nf"&gt;getManagementToken&lt;/span&gt;&lt;span class="p"&gt;():&lt;/span&gt; &lt;span class="nb"&gt;Promise&lt;/span&gt;&lt;span class="o"&gt;&amp;lt;&lt;/span&gt;&lt;span class="kr"&gt;string&lt;/span&gt;&lt;span class="o"&gt;&amp;gt;&lt;/span&gt; &lt;span class="p"&gt;{&lt;/span&gt;
  &lt;span class="kd"&gt;const&lt;/span&gt; &lt;span class="nx"&gt;res&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="k"&gt;await&lt;/span&gt; &lt;span class="nf"&gt;fetch&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="s2"&gt;`https://&lt;/span&gt;&lt;span class="p"&gt;${&lt;/span&gt;&lt;span class="nx"&gt;process&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nx"&gt;env&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nx"&gt;KINDE_DOMAIN&lt;/span&gt;&lt;span class="p"&gt;}&lt;/span&gt;&lt;span class="s2"&gt;/oauth2/token`&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="p"&gt;{&lt;/span&gt;
    &lt;span class="na"&gt;method&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="dl"&gt;"&lt;/span&gt;&lt;span class="s2"&gt;POST&lt;/span&gt;&lt;span class="dl"&gt;"&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt;
    &lt;span class="na"&gt;headers&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="p"&gt;{&lt;/span&gt; &lt;span class="dl"&gt;"&lt;/span&gt;&lt;span class="s2"&gt;Content-Type&lt;/span&gt;&lt;span class="dl"&gt;"&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="dl"&gt;"&lt;/span&gt;&lt;span class="s2"&gt;application/x-www-form-urlencoded&lt;/span&gt;&lt;span class="dl"&gt;"&lt;/span&gt; &lt;span class="p"&gt;},&lt;/span&gt;
    &lt;span class="na"&gt;body&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="k"&gt;new&lt;/span&gt; &lt;span class="nc"&gt;URLSearchParams&lt;/span&gt;&lt;span class="p"&gt;({&lt;/span&gt;
      &lt;span class="na"&gt;grant_type&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="dl"&gt;"&lt;/span&gt;&lt;span class="s2"&gt;client_credentials&lt;/span&gt;&lt;span class="dl"&gt;"&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt;
      &lt;span class="na"&gt;client_id&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="nx"&gt;process&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nx"&gt;env&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nx"&gt;KINDE_M2M_CLIENT_ID&lt;/span&gt;&lt;span class="o"&gt;!&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt;
      &lt;span class="na"&gt;client_secret&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="nx"&gt;process&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nx"&gt;env&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nx"&gt;KINDE_M2M_CLIENT_SECRET&lt;/span&gt;&lt;span class="o"&gt;!&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt;
      &lt;span class="c1"&gt;// The Management API audience for your Kinde domain.&lt;/span&gt;
      &lt;span class="na"&gt;audience&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="s2"&gt;`https://&lt;/span&gt;&lt;span class="p"&gt;${&lt;/span&gt;&lt;span class="nx"&gt;process&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nx"&gt;env&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nx"&gt;KINDE_DOMAIN&lt;/span&gt;&lt;span class="p"&gt;}&lt;/span&gt;&lt;span class="s2"&gt;/api`&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;if &lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="o"&gt;!&lt;/span&gt;&lt;span class="nx"&gt;res&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nx"&gt;ok&lt;/span&gt;&lt;span class="p"&gt;)&lt;/span&gt; &lt;span class="p"&gt;{&lt;/span&gt;
    &lt;span class="k"&gt;throw&lt;/span&gt; &lt;span class="k"&gt;new&lt;/span&gt; &lt;span class="nc"&gt;Error&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="s2"&gt;`Token request failed: &lt;/span&gt;&lt;span class="p"&gt;${&lt;/span&gt;&lt;span class="nx"&gt;res&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nx"&gt;status&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="kd"&gt;const&lt;/span&gt; &lt;span class="p"&gt;{&lt;/span&gt; &lt;span class="nx"&gt;access_token&lt;/span&gt; &lt;span class="p"&gt;}&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="k"&gt;await&lt;/span&gt; &lt;span class="nx"&gt;res&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nf"&gt;json&lt;/span&gt;&lt;span class="p"&gt;();&lt;/span&gt;
  &lt;span class="k"&gt;return&lt;/span&gt; &lt;span class="nx"&gt;access_token&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 token is valid for a limited window, so a real provisioning service caches it and refreshes only when it is close to expiry rather than fetching a fresh one on every call.&lt;/p&gt;

&lt;h2&gt;
  
  
  Step 2: Create a SAML connection in one call
&lt;/h2&gt;

&lt;p&gt;With a token in hand, creating a connection is a single authenticated POST to &lt;code&gt;/api/v1/connections&lt;/code&gt;. The body carries a name, the strategy, and the SAML options that describe how Kinde and the customer's identity provider will understand each other. Kinde namespaces its strategy values, so a custom SAML connection uses &lt;code&gt;saml:custom&lt;/code&gt;, sitting alongside built-in strategies like &lt;code&gt;email:password&lt;/code&gt; and provider-specific ones like &lt;code&gt;saml:google&lt;/code&gt;.&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight typescript"&gt;&lt;code&gt;&lt;span class="k"&gt;async&lt;/span&gt; &lt;span class="kd"&gt;function&lt;/span&gt; &lt;span class="nf"&gt;createSamlConnection&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="nx"&gt;token&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="kr"&gt;string&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="nx"&gt;input&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="p"&gt;{&lt;/span&gt; &lt;span class="nl"&gt;name&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="kr"&gt;string&lt;/span&gt; &lt;span class="p"&gt;})&lt;/span&gt; &lt;span class="p"&gt;{&lt;/span&gt;
  &lt;span class="kd"&gt;const&lt;/span&gt; &lt;span class="nx"&gt;res&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="k"&gt;await&lt;/span&gt; &lt;span class="nf"&gt;fetch&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="s2"&gt;`https://&lt;/span&gt;&lt;span class="p"&gt;${&lt;/span&gt;&lt;span class="nx"&gt;process&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nx"&gt;env&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nx"&gt;KINDE_DOMAIN&lt;/span&gt;&lt;span class="p"&gt;}&lt;/span&gt;&lt;span class="s2"&gt;/api/v1/connections`&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="p"&gt;{&lt;/span&gt;
    &lt;span class="na"&gt;method&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="dl"&gt;"&lt;/span&gt;&lt;span class="s2"&gt;POST&lt;/span&gt;&lt;span class="dl"&gt;"&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt;
    &lt;span class="na"&gt;headers&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="p"&gt;{&lt;/span&gt;
      &lt;span class="na"&gt;Authorization&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="s2"&gt;`Bearer &lt;/span&gt;&lt;span class="p"&gt;${&lt;/span&gt;&lt;span class="nx"&gt;token&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="dl"&gt;"&lt;/span&gt;&lt;span class="s2"&gt;Content-Type&lt;/span&gt;&lt;span class="dl"&gt;"&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="dl"&gt;"&lt;/span&gt;&lt;span class="s2"&gt;application/json&lt;/span&gt;&lt;span class="dl"&gt;"&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt;
    &lt;span class="p"&gt;},&lt;/span&gt;
    &lt;span class="na"&gt;body&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;name&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="nx"&gt;input&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nx"&gt;name&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt;
      &lt;span class="na"&gt;strategy&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="dl"&gt;"&lt;/span&gt;&lt;span class="s2"&gt;saml:custom&lt;/span&gt;&lt;span class="dl"&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;span class="k"&gt;if &lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="o"&gt;!&lt;/span&gt;&lt;span class="nx"&gt;res&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nx"&gt;ok&lt;/span&gt;&lt;span class="p"&gt;)&lt;/span&gt; &lt;span class="p"&gt;{&lt;/span&gt;
    &lt;span class="kd"&gt;const&lt;/span&gt; &lt;span class="nx"&gt;error&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="k"&gt;await&lt;/span&gt; &lt;span class="nx"&gt;res&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nf"&gt;json&lt;/span&gt;&lt;span class="p"&gt;();&lt;/span&gt;
    &lt;span class="k"&gt;throw&lt;/span&gt; &lt;span class="k"&gt;new&lt;/span&gt; &lt;span class="nc"&gt;Error&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="s2"&gt;`Create failed (&lt;/span&gt;&lt;span class="p"&gt;${&lt;/span&gt;&lt;span class="nx"&gt;res&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nx"&gt;status&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="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="nx"&gt;error&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="c1"&gt;// 201 → { code: "CONNECTION_CREATED", message: "...", connection: { id: "conn_..." } }&lt;/span&gt;
  &lt;span class="k"&gt;return&lt;/span&gt; &lt;span class="nx"&gt;res&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nf"&gt;json&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 minimal create needs only &lt;code&gt;name&lt;/code&gt; and &lt;code&gt;strategy&lt;/code&gt;, and Kinde returns &lt;code&gt;201&lt;/code&gt; with the new &lt;code&gt;connection.id&lt;/code&gt;. You then add the SAML specifics through the connection's &lt;code&gt;options&lt;/code&gt; object, either on this create call or in a follow-up update once you have the customer's details. The fields that live under &lt;code&gt;options&lt;/code&gt; are covered next.&lt;/p&gt;

&lt;p&gt;A successful call returns the new connection's identifier in &lt;code&gt;connection.id&lt;/code&gt;, and you will want to store that against the customer's record, because it is the handle you use for every later update or deletion. After the call succeeds, the connection exists in Kinde and you can confirm it in the dashboard exactly as if a human had created it.&lt;/p&gt;

&lt;p&gt;&lt;a href="https://media2.dev.to/dynamic/image/width=800%2Cheight=%2Cfit=scale-down%2Cgravity=auto%2Cformat=auto/https%3A%2F%2Fdev-to-uploads.s3.us-east-2.amazonaws.com%2Fuploads%2Farticles%2F0q3u4qp9lbun1b14m7mr.png" class="article-body-image-wrapper"&gt;&lt;img src="https://media2.dev.to/dynamic/image/width=800%2Cheight=%2Cfit=scale-down%2Cgravity=auto%2Cformat=auto/https%3A%2F%2Fdev-to-uploads.s3.us-east-2.amazonaws.com%2Fuploads%2Farticles%2F0q3u4qp9lbun1b14m7mr.png" alt="The newly created SAML connection as it appears in the Kinde dashboard's enterprise connection list, sitting on the customer's organization" width="799" height="503"&gt;&lt;/a&gt;&lt;/p&gt;

&lt;h2&gt;
  
  
  Step 3: Map what the customer sends you onto the right fields
&lt;/h2&gt;

&lt;p&gt;Mapping the customer's details onto the four fields separates a script that works once from a provisioning system that holds up across dozens of different identity providers. No reference page walks you through it, because it is about judgement rather than syntax.&lt;/p&gt;

&lt;p&gt;When a customer's IT team hands you their SAML details, they are giving you raw material that you have to translate into the four fields. Knowing which value goes where, and why each one matters, is what lets your service handle Okta, Entra, Google Workspace, and the long tail of less common providers without a human in the loop.&lt;/p&gt;

&lt;div class="table-wrapper-paragraph"&gt;&lt;table&gt;
&lt;thead&gt;
&lt;tr&gt;
&lt;th&gt;Field&lt;/th&gt;
&lt;th&gt;What it controls&lt;/th&gt;
&lt;th&gt;How to decide its value&lt;/th&gt;
&lt;/tr&gt;
&lt;/thead&gt;
&lt;tbody&gt;
&lt;tr&gt;
&lt;td&gt;&lt;code&gt;idp_metadata_url&lt;/code&gt;&lt;/td&gt;
&lt;td&gt;The public URL Kinde reads to learn the IdP's endpoints and signing certificate&lt;/td&gt;
&lt;td&gt;Comes straight from the customer. Note that some providers, Google among them, will not host this at a stable URL, so the customer may give you a self-hosted link instead&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;&lt;code&gt;name_id_format&lt;/code&gt;&lt;/td&gt;
&lt;td&gt;How the user's unique identifier is represented in the assertion&lt;/td&gt;
&lt;td&gt;Dictated by what the customer's IdP supports and is configured to send. Confirm it with their admin rather than assuming, since a mismatch here breaks sign-in&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;&lt;code&gt;saml_user_id_key_attr&lt;/code&gt;&lt;/td&gt;
&lt;td&gt;Which attribute in the assertion Kinde treats as the user identifier&lt;/td&gt;
&lt;td&gt;Set this when the IdP carries the user ID in a non-standard attribute. Getting it right is what keeps a returning user mapped to the same Kinde identity instead of spawning duplicates&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;&lt;code&gt;sign_request_algorithm&lt;/code&gt;&lt;/td&gt;
&lt;td&gt;The algorithm used to sign the SAML request&lt;/td&gt;
&lt;td&gt;Use &lt;code&gt;rsa-sha256&lt;/code&gt;, the modern secure choice. Only fall back to &lt;code&gt;rsa-sha1&lt;/code&gt; when a particular IdP still requires it, and treat that as a temporary accommodation&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;&lt;code&gt;protocol_binding&lt;/code&gt;&lt;/td&gt;
&lt;td&gt;The transport used to send the request to the IdP&lt;/td&gt;
&lt;td&gt;Use &lt;code&gt;HTTP-POST&lt;/code&gt; for signed requests and larger payloads, which is the common case, and switch to the redirect binding only when the IdP supports nothing else&lt;/td&gt;
&lt;/tr&gt;
&lt;/tbody&gt;
&lt;/table&gt;&lt;/div&gt;

&lt;p&gt;Secure defaults matter because your provisioning service is making these choices for every customer at once. If your default signing algorithm is the strong one and you only step down when an IdP forces it, then your whole customer base is secure by default and the exceptions are visible and deliberate. If you let each customer's IT team push you toward whatever is easiest on their side, you end up with a fleet of connections whose security posture you cannot reason about.&lt;/p&gt;

&lt;p&gt;&lt;a href="https://media2.dev.to/dynamic/image/width=800%2Cheight=%2Cfit=scale-down%2Cgravity=auto%2Cformat=auto/https%3A%2F%2Fdev-to-uploads.s3.us-east-2.amazonaws.com%2Fuploads%2Farticles%2Femqnhbbyf2a5v808oqq0.png" class="article-body-image-wrapper"&gt;&lt;img src="https://media2.dev.to/dynamic/image/width=800%2Cheight=%2Cfit=scale-down%2Cgravity=auto%2Cformat=auto/https%3A%2F%2Fdev-to-uploads.s3.us-east-2.amazonaws.com%2Fuploads%2Farticles%2Femqnhbbyf2a5v808oqq0.png" alt="" id="" width="800" height="551"&gt;&lt;/a&gt;&lt;/p&gt;

&lt;h2&gt;
  
  
  Step 4: Update in place, or replace entirely
&lt;/h2&gt;

&lt;p&gt;Connections are not static. Certificates rotate, customers migrate from one identity provider to another, and occasionally a field was simply set wrong and needs correcting. Kinde handles all of these through a single &lt;code&gt;PATCH /api/v1/connections/{id}&lt;/code&gt; call, and the distinction that matters is how much of the &lt;code&gt;options&lt;/code&gt; object you send.&lt;/p&gt;

&lt;p&gt;A &lt;strong&gt;partial update&lt;/strong&gt; changes specific fields while leaving the rest of the connection as it is. This is what you reach for in the common cases. A customer's IdP rotates its signing certificate and republishes its metadata, and you patch the metadata URL. A customer asks to move from the older signing algorithm to the modern one, and you patch that single field. These updates are surgical, and they are the right default. A successful call returns &lt;code&gt;{"code": "CONNECTION_UPDATED"}&lt;/code&gt;.&lt;/p&gt;

&lt;p&gt;A &lt;strong&gt;full replacement&lt;/strong&gt; sends the entire &lt;code&gt;options&lt;/code&gt; object when the change is wholesale rather than incremental, the clearest example being a customer migrating from, say, Okta to Entra ID, where almost everything about the connection is now different. You use the same PATCH endpoint, but you send the complete new configuration rather than a single field.&lt;/p&gt;

&lt;p&gt;&lt;a href="https://media2.dev.to/dynamic/image/width=800%2Cheight=%2Cfit=scale-down%2Cgravity=auto%2Cformat=auto/https%3A%2F%2Fdev-to-uploads.s3.us-east-2.amazonaws.com%2Fuploads%2Farticles%2Fng7hyx18vbkwsmojkoge.png" class="article-body-image-wrapper"&gt;&lt;img src="https://media2.dev.to/dynamic/image/width=800%2Cheight=%2Cfit=scale-down%2Cgravity=auto%2Cformat=auto/https%3A%2F%2Fdev-to-uploads.s3.us-east-2.amazonaws.com%2Fuploads%2Farticles%2Fng7hyx18vbkwsmojkoge.png" alt="A small decision flow. Start node: " width="800" height="1132"&gt;&lt;/a&gt;&lt;/p&gt;

&lt;p&gt;One trap bites people who treat a wholesale change carelessly. When you send an &lt;code&gt;options&lt;/code&gt; object, treat it as the new state of those fields, so read the current connection first, merge your changes onto what is already there, and send the complete object. If you intend a full replacement and forget to include the signing algorithm you had carefully set, you risk losing it. Treat a partial patch as "change exactly these fields" and a full replacement as "here is the complete new configuration", and the trap disappears.&lt;/p&gt;

&lt;h2&gt;
  
  
  Step 5: Let validation fail fast for you
&lt;/h2&gt;

&lt;p&gt;&lt;a href="https://kinde.com/?utm_source=devto&amp;amp;utm_medium=content&amp;amp;utm_campaign=shola&amp;amp;campaignid=chatgptapp&amp;amp;network=&amp;amp;adgroup=&amp;amp;keyword=&amp;amp;matchtype=&amp;amp;creative=18&amp;amp;device=&amp;amp;adposition=" rel="noopener noreferrer"&gt;Kinde&lt;/a&gt; recently changed how it handles bad values. Previously, an invalid enum value might be accepted silently and quietly corrected at runtime, which is the kind of behaviour that hides a misconfiguration until a user cannot sign in. Now an invalid value for one of these fields returns a clear 400 error immediately.&lt;/p&gt;

&lt;p&gt;For a provisioning service, this is a gift rather than an inconvenience. It means a malformed configuration fails at the moment you try to create it, in your pipeline, where you can catch it and surface it, instead of failing days later in a customer's sign-in flow where it becomes a support escalation. Let the 400 propagate into your onboarding system as a visible, actionable error.&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight typescript"&gt;&lt;code&gt;&lt;span class="k"&gt;try&lt;/span&gt; &lt;span class="p"&gt;{&lt;/span&gt;
  &lt;span class="kd"&gt;const&lt;/span&gt; &lt;span class="nx"&gt;connection&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="k"&gt;await&lt;/span&gt; &lt;span class="nf"&gt;createSamlConnection&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="nx"&gt;token&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="nx"&gt;input&lt;/span&gt;&lt;span class="p"&gt;);&lt;/span&gt;
  &lt;span class="k"&gt;await&lt;/span&gt; &lt;span class="nf"&gt;saveConnectionId&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="nx"&gt;input&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nx"&gt;organizationCode&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="nx"&gt;connection&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nx"&gt;id&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="na"&gt;ok&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="kc"&gt;true&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="na"&gt;connectionId&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="nx"&gt;connection&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nx"&gt;id&lt;/span&gt; &lt;span class="p"&gt;};&lt;/span&gt;
&lt;span class="p"&gt;}&lt;/span&gt; &lt;span class="k"&gt;catch &lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="nx"&gt;err&lt;/span&gt;&lt;span class="p"&gt;)&lt;/span&gt; &lt;span class="p"&gt;{&lt;/span&gt;
  &lt;span class="c1"&gt;// A 400 here means a bad field value. Surface it to your onboarding UI&lt;/span&gt;
  &lt;span class="c1"&gt;// so it is fixed now, not discovered by the customer in production.&lt;/span&gt;
  &lt;span class="k"&gt;return&lt;/span&gt; &lt;span class="p"&gt;{&lt;/span&gt; &lt;span class="na"&gt;ok&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="kc"&gt;false&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="na"&gt;reason&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="nx"&gt;err&lt;/span&gt; &lt;span class="k"&gt;as&lt;/span&gt; &lt;span class="nb"&gt;Error&lt;/span&gt;&lt;span class="p"&gt;).&lt;/span&gt;&lt;span class="nx"&gt;message&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;Your provisioning service should treat connection creation the way you treat any other deploy. It either succeeds cleanly or it tells you precisely what was wrong, and a customer never becomes the person who discovers the mistake.&lt;/p&gt;

&lt;p&gt;&lt;a href="https://media2.dev.to/dynamic/image/width=800%2Cheight=%2Cfit=scale-down%2Cgravity=auto%2Cformat=auto/https%3A%2F%2Fdev-to-uploads.s3.us-east-2.amazonaws.com%2Fuploads%2Farticles%2Frdkncjgwh4soovtsux1v.png" class="article-body-image-wrapper"&gt;&lt;img src="https://media2.dev.to/dynamic/image/width=800%2Cheight=%2Cfit=scale-down%2Cgravity=auto%2Cformat=auto/https%3A%2F%2Fdev-to-uploads.s3.us-east-2.amazonaws.com%2Fuploads%2Farticles%2Frdkncjgwh4soovtsux1v.png" alt="A terminal view showing two responses stacked: a successful create returning the new connection body ( raw `code: CONNECTION_CREATED` endraw  with the  raw `connection.id` endraw ), and a deliberately broken create with an invalid  raw `sign_request_algorithm` endraw  value returning a 400 with  raw `code: INVALID_SIGN_REQUEST_ALGORITHM` endraw " width="800" height="518"&gt;&lt;/a&gt;&lt;/p&gt;

&lt;h2&gt;
  
  
  Step 6: The full lifecycle
&lt;/h2&gt;

&lt;p&gt;Putting the pieces together, a provisioning service is a small, well-defined surface that your onboarding flow calls into. It provisions a connection when a customer becomes enterprise, updates it as the customer's setup changes over time, and removes it when the customer leaves so you are not carrying dead connections.&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight typescript"&gt;&lt;code&gt;&lt;span class="kd"&gt;class&lt;/span&gt; &lt;span class="nc"&gt;EnterpriseSsoProvisioner&lt;/span&gt; &lt;span class="p"&gt;{&lt;/span&gt;
  &lt;span class="c1"&gt;// Called when a customer upgrades to an enterprise plan.&lt;/span&gt;
  &lt;span class="k"&gt;async&lt;/span&gt; &lt;span class="nf"&gt;provision&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="nx"&gt;idp&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="p"&gt;{&lt;/span&gt; &lt;span class="nl"&gt;name&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="kr"&gt;string&lt;/span&gt;&lt;span class="p"&gt;;&lt;/span&gt; &lt;span class="nl"&gt;idpMetadataUrl&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="kr"&gt;string&lt;/span&gt;&lt;span class="p"&gt;;&lt;/span&gt; &lt;span class="nl"&gt;signRequestAlgorithm&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="dl"&gt;"&lt;/span&gt;&lt;span class="s2"&gt;rsa-sha256&lt;/span&gt;&lt;span class="dl"&gt;"&lt;/span&gt; &lt;span class="o"&gt;|&lt;/span&gt; &lt;span class="dl"&gt;"&lt;/span&gt;&lt;span class="s2"&gt;rsa-sha1&lt;/span&gt;&lt;span class="dl"&gt;"&lt;/span&gt; &lt;span class="p"&gt;})&lt;/span&gt; &lt;span class="p"&gt;{&lt;/span&gt;
    &lt;span class="kd"&gt;const&lt;/span&gt; &lt;span class="nx"&gt;token&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="k"&gt;await&lt;/span&gt; &lt;span class="nf"&gt;getManagementToken&lt;/span&gt;&lt;span class="p"&gt;();&lt;/span&gt;
    &lt;span class="kd"&gt;const&lt;/span&gt; &lt;span class="p"&gt;{&lt;/span&gt; &lt;span class="nx"&gt;connection&lt;/span&gt; &lt;span class="p"&gt;}&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="k"&gt;await&lt;/span&gt; &lt;span class="nf"&gt;createSamlConnection&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="nx"&gt;token&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="nx"&gt;idp&lt;/span&gt;&lt;span class="p"&gt;);&lt;/span&gt;
    &lt;span class="k"&gt;await&lt;/span&gt; &lt;span class="nf"&gt;saveConnectionId&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="nx"&gt;connection&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nx"&gt;id&lt;/span&gt;&lt;span class="p"&gt;);&lt;/span&gt;
    &lt;span class="k"&gt;return&lt;/span&gt; &lt;span class="k"&gt;this&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nf"&gt;handoffDetails&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="nx"&gt;connection&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nx"&gt;id&lt;/span&gt;&lt;span class="p"&gt;);&lt;/span&gt; &lt;span class="c1"&gt;// ACS URL + Entity ID for the customer's admin&lt;/span&gt;
  &lt;span class="p"&gt;}&lt;/span&gt;

  &lt;span class="c1"&gt;// Certificate rotation, algorithm change, fixing a single field.&lt;/span&gt;
  &lt;span class="c1"&gt;// PATCH /api/v1/connections/{id} with only the fields that moved.&lt;/span&gt;
  &lt;span class="k"&gt;async&lt;/span&gt; &lt;span class="nf"&gt;patch&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="nx"&gt;connectionId&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="kr"&gt;string&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="nx"&gt;changes&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="nb"&gt;Partial&lt;/span&gt;&lt;span class="o"&gt;&amp;lt;&lt;/span&gt;&lt;span class="nx"&gt;SamlOptions&lt;/span&gt;&lt;span class="o"&gt;&amp;gt;&lt;/span&gt;&lt;span class="p"&gt;)&lt;/span&gt; &lt;span class="p"&gt;{&lt;/span&gt;
    &lt;span class="kd"&gt;const&lt;/span&gt; &lt;span class="nx"&gt;token&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="k"&gt;await&lt;/span&gt; &lt;span class="nf"&gt;getManagementToken&lt;/span&gt;&lt;span class="p"&gt;();&lt;/span&gt;
    &lt;span class="k"&gt;return&lt;/span&gt; &lt;span class="nf"&gt;patchConnection&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="nx"&gt;token&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="nx"&gt;connectionId&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="p"&gt;{&lt;/span&gt; &lt;span class="na"&gt;options&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="nx"&gt;changes&lt;/span&gt; &lt;span class="p"&gt;});&lt;/span&gt;
  &lt;span class="p"&gt;}&lt;/span&gt;

  &lt;span class="c1"&gt;// Customer migrates to a different IdP: send the complete new options object.&lt;/span&gt;
  &lt;span class="k"&gt;async&lt;/span&gt; &lt;span class="nf"&gt;replace&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="nx"&gt;connectionId&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="kr"&gt;string&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="nx"&gt;fullOptions&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="nx"&gt;SamlOptions&lt;/span&gt;&lt;span class="p"&gt;)&lt;/span&gt; &lt;span class="p"&gt;{&lt;/span&gt;
    &lt;span class="kd"&gt;const&lt;/span&gt; &lt;span class="nx"&gt;token&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="k"&gt;await&lt;/span&gt; &lt;span class="nf"&gt;getManagementToken&lt;/span&gt;&lt;span class="p"&gt;();&lt;/span&gt;
    &lt;span class="k"&gt;return&lt;/span&gt; &lt;span class="nf"&gt;patchConnection&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="nx"&gt;token&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="nx"&gt;connectionId&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="p"&gt;{&lt;/span&gt; &lt;span class="na"&gt;options&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="nx"&gt;fullOptions&lt;/span&gt; &lt;span class="p"&gt;});&lt;/span&gt;
  &lt;span class="p"&gt;}&lt;/span&gt;

  &lt;span class="c1"&gt;// Customer churns or downgrades.&lt;/span&gt;
  &lt;span class="c1"&gt;// DELETE /api/v1/connections/{id}&lt;/span&gt;
  &lt;span class="k"&gt;async&lt;/span&gt; &lt;span class="nf"&gt;deprovision&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="nx"&gt;connectionId&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="kr"&gt;string&lt;/span&gt;&lt;span class="p"&gt;)&lt;/span&gt; &lt;span class="p"&gt;{&lt;/span&gt;
    &lt;span class="kd"&gt;const&lt;/span&gt; &lt;span class="nx"&gt;token&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="k"&gt;await&lt;/span&gt; &lt;span class="nf"&gt;getManagementToken&lt;/span&gt;&lt;span class="p"&gt;();&lt;/span&gt;
    &lt;span class="k"&gt;await&lt;/span&gt; &lt;span class="nf"&gt;deleteConnection&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="nx"&gt;token&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="nx"&gt;connectionId&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;The shape here is the real deliverable. Your sales and onboarding flows do not know or care about SAML internals. They call &lt;code&gt;provision&lt;/code&gt; when a deal closes and &lt;code&gt;deprovision&lt;/code&gt; when it ends, and the messy reality of metadata URLs and binding choices lives entirely inside this one service.&lt;/p&gt;

&lt;p&gt;&lt;a href="https://media2.dev.to/dynamic/image/width=800%2Cheight=%2Cfit=scale-down%2Cgravity=auto%2Cformat=auto/https%3A%2F%2Fdev-to-uploads.s3.us-east-2.amazonaws.com%2Fuploads%2Farticles%2Flanhh92avqcgizdn17zv.png" class="article-body-image-wrapper"&gt;&lt;img src="https://media2.dev.to/dynamic/image/width=800%2Cheight=%2Cfit=scale-down%2Cgravity=auto%2Cformat=auto/https%3A%2F%2Fdev-to-uploads.s3.us-east-2.amazonaws.com%2Fuploads%2Farticles%2Flanhh92avqcgizdn17zv.png" alt="Connection lifecycle as a simple state path. PROVISIONED (created via POST) loops back on itself for certificate rotation or algorithm change (PATCH a few fields) and for IdP migration (PATCH the full options object), then moves to DEPROVISIONED (DELETE). Label each transition with the real-world event that triggers it. This gives the reader a mental model of a connection's whole life rather than four disconnected API calls." width="800" height="352"&gt;&lt;/a&gt;&lt;/p&gt;

&lt;h2&gt;
  
  
  Step 7: Test the whole thing without a customer
&lt;/h2&gt;

&lt;p&gt;You do not need a real enterprise customer, or even a real identity provider, to build and verify this. A throwaway Kinde account with a test organization is enough to exercise every call, and a few curl commands let you watch the real request and response shapes before you wire any of it into your product.&lt;/p&gt;

&lt;p&gt;Run the create call against your test organization and confirm the connection appears in the dashboard. Send a deliberately invalid enum value and confirm you get the 400, so you know your error handling works. Update a single field and confirm only that field changed. Then delete it and confirm it is gone. Once those four behave the way you expect, you have validated the entire surface your provisioning service depends on, and you can build the rest with confidence.&lt;/p&gt;

&lt;h2&gt;
  
  
  Wrapping up
&lt;/h2&gt;

&lt;p&gt;The forty-customer problem from the opening is, underneath, a question of whether SAML setup is something a person does or something your system does. Hand-configuring connections is fine until it is not, and the point where it stops scaling arrives sooner than most teams expect, usually right as enterprise deals start closing in earnest. Moving that work into the Management API turns a recurring manual chore into a provisioning service that runs as a natural part of onboarding, fails loudly and early when a value is wrong, and handles the full life of a connection from creation through certificate rotations to eventual teardown.&lt;/p&gt;

&lt;p&gt;You authenticate once with a machine-to-machine application, create connections with a single call, map each customer's details onto the four fields that matter, choose update or replace depending on how much is changing, and lean on Kinde's strict validation to keep broken configurations out of production. The result is an onboarding flow where an enterprise customer's SSO is ready before their IT admin has finished reading the welcome email.&lt;/p&gt;

&lt;p&gt;Kinde is free for up to 10,500 monthly active users, with no credit card required to start. You can create an account, set up a machine-to-machine application, and try these calls against a test organization at &lt;a href="https://kinde.com/?utm_source=devto&amp;amp;utm_medium=content&amp;amp;utm_campaign=shola&amp;amp;campaignid=chatgptapp&amp;amp;network=&amp;amp;adgroup=&amp;amp;keyword=&amp;amp;matchtype=&amp;amp;creative=18&amp;amp;device=&amp;amp;adposition=" rel="noopener noreferrer"&gt;kinde.com&lt;/a&gt;, and the full Management API reference documents every field and operation in detail.&lt;/p&gt;

&lt;p&gt;&lt;em&gt;I write about identity, billing, and the infrastructure behind multi-tenant and AI products. If provisioning systems like this are your world, follow along for more.&lt;/em&gt;&lt;/p&gt;

</description>
      <category>webdev</category>
      <category>kinde</category>
      <category>programming</category>
      <category>tutorial</category>
    </item>
    <item>
      <title>How to Support IdP-Initiated SSO in Your App With Kinde</title>
      <dc:creator>Shola Jegede</dc:creator>
      <pubDate>Sat, 20 Jun 2026 18:39:02 +0000</pubDate>
      <link>https://dev.to/sholajegede/how-to-support-idp-initiated-sso-in-your-app-with-kinde-59n3</link>
      <guid>https://dev.to/sholajegede/how-to-support-idp-initiated-sso-in-your-app-with-kinde-59n3</guid>
      <description>&lt;p&gt;&lt;em&gt;Your enterprise customer just emailed: "Our employees can't find your app in Okta. Can you add a tile?" If you have no idea what they mean, this article is for you. By the end of it you will know exactly what they are asking for, why the deal might stall without it, and how to deliver it with Kinde in an afternoon.&lt;/em&gt;&lt;/p&gt;

&lt;p&gt;When you sell software to enterprises, the people who actually log in every day rarely navigate to your app the way a consumer would. They sit inside a company portal, something like Okta, Microsoft Entra ID, or Google Workspace, and they launch every approved application from a single dashboard of tiles. If your app is not one of those tiles, it does not really exist to them, and the IT team that controls that dashboard will tell you so in plain terms.&lt;/p&gt;

&lt;p&gt;That request, "add a tile to our portal," is a request for IdP-initiated single sign-on. It is one of the quieter blockers in B2B sales, because it almost never comes up in a demo and almost always comes up right before a contract is signed. This guide walks through what the flow is, why enterprises insist on it, the security concern that has historically made it risky, and how Kinde handles the whole thing so that your application code barely changes.&lt;/p&gt;

&lt;p&gt;What you will learn&lt;/p&gt;

&lt;p&gt;What IdP-initiated SSO is, and how it differs from the SP-initiated flow you already know&lt;br&gt;
Why enterprise IT teams require it before they will deploy your app&lt;br&gt;
The security risk that is unique to this flow, and how Kinde absorbs it for you&lt;br&gt;
How to enable IdP-initiated SSO on a Kinde enterprise connection&lt;br&gt;
How to set up the matching app tile in Okta, Microsoft Entra ID, and Google Workspace&lt;br&gt;
Exactly what to send your customer's IT admin so they can finish their side&lt;br&gt;
How to test both directions of the flow before you go live&lt;/p&gt;
&lt;h2&gt;
  
  
  The two directions of SAML
&lt;/h2&gt;

&lt;p&gt;Every SAML single sign-on flow has two participants. The Identity Provider, or IdP, is the system that owns the user's identity and verifies who they are. That is the customer's Okta, Entra ID, or Google Workspace. The Service Provider, or SP, is the application the user wants to reach. In a Kinde setup, Kinde is the Service Provider, and your app sits behind Kinde.&lt;/p&gt;

&lt;p&gt;The difference between the two flows comes down to one question: who starts the login?&lt;/p&gt;

&lt;p&gt;In SP-initiated SSO, the user starts at your app. They click a sign-in button, your app hands off to Kinde, Kinde routes them to their IdP, they authenticate, and they come back authenticated. This is the flow most developers build first, and it is the one most consumer logins use.&lt;/p&gt;

&lt;p&gt;In IdP-initiated SSO, the user starts inside their company portal. They are already logged into Okta or Entra ID for the day, they click your app's tile, and they land in your app already authenticated. Your app never sent them anywhere. The login simply arrives.&lt;/p&gt;

&lt;p&gt;&lt;a href="https://media2.dev.to/dynamic/image/width=800%2Cheight=%2Cfit=scale-down%2Cgravity=auto%2Cformat=auto/https%3A%2F%2Fdev-to-uploads.s3.us-east-2.amazonaws.com%2Fuploads%2Farticles%2Fac5tmpl99tjznzvh8nuh.png" class="article-body-image-wrapper"&gt;&lt;img src="https://media2.dev.to/dynamic/image/width=800%2Cheight=%2Cfit=scale-down%2Cgravity=auto%2Cformat=auto/https%3A%2F%2Fdev-to-uploads.s3.us-east-2.amazonaws.com%2Fuploads%2Farticles%2Fac5tmpl99tjznzvh8nuh.png" alt="Two horizontal flows stacked vertically, side-by-side comparison" width="700" height="3320"&gt;&lt;/a&gt;&lt;/p&gt;

&lt;p&gt;&lt;em&gt;In SP-initiated flow your app starts the conversation. In IdP-initiated flow the identity provider does, and the assertion arrives unsolicited.&lt;/em&gt;&lt;/p&gt;

&lt;p&gt;These are two entry points into the same destination. A single enterprise connection in Kinde can serve both, and most enterprise customers will end up using both depending on where their employees happen to be when they need your app.&lt;/p&gt;
&lt;h2&gt;
  
  
  Why enterprise customers ask for this
&lt;/h2&gt;

&lt;p&gt;Large organizations do not let employees manage their own logins for dozens of business applications. Instead, the IT team maintains a central portal where every approved app appears as a tile, and that portal becomes the front door to the entire software stack. There are real reasons this matters to them, and understanding those reasons helps you have the right conversation when the request lands.&lt;/p&gt;

&lt;p&gt;The first reason is simply how people work. An employee at a financial services firm opens their Okta dashboard in the morning and launches everything from there. They do not bookmark your app's login page, and many of them would not be able to find it if asked. If your app is missing from the portal, adoption inside that company quietly stalls, because the path of least resistance does not include you.&lt;/p&gt;

&lt;p&gt;The second reason is governance. Enterprise IT teams need every application that touches company data to be visible in their identity stack, both so they can provision access centrally and so they can revoke it the instant someone leaves. When a thirty-person team is deactivated, IT expects that turning off the account in their IdP turns off access everywhere, including your app, on the same day.&lt;/p&gt;

&lt;p&gt;An application that lives outside that system is a gap in their audit trail, and that gap is exactly the kind of thing that fails a SOC 2 or ISO 27001 review.&lt;/p&gt;

&lt;p&gt;So when a customer asks for a tile, they are really asking whether your app can become a well-behaved citizen of their identity governance system. IdP-initiated SSO is a large part of the answer, and being able to say yes quickly is often what keeps the deal moving.&lt;/p&gt;

&lt;p&gt;&lt;a href="https://media2.dev.to/dynamic/image/width=800%2Cheight=%2Cfit=scale-down%2Cgravity=auto%2Cformat=auto/https%3A%2F%2Fdev-to-uploads.s3.us-east-2.amazonaws.com%2Fuploads%2Farticles%2Fhznbqi2oj5gblal1ea25.png" class="article-body-image-wrapper"&gt;&lt;img src="https://media2.dev.to/dynamic/image/width=800%2Cheight=%2Cfit=scale-down%2Cgravity=auto%2Cformat=auto/https%3A%2F%2Fdev-to-uploads.s3.us-east-2.amazonaws.com%2Fuploads%2Farticles%2Fhznbqi2oj5gblal1ea25.png" alt="The Google Workspace app launcher (waffle grid) showing the DevRel Studio tile among the other apps. This is what the customer wants to see at the end." width="750" height="1254"&gt;&lt;/a&gt;&lt;/p&gt;
&lt;h2&gt;
  
  
  The security risk you should understand first
&lt;/h2&gt;

&lt;p&gt;IdP-initiated SSO has a reputation among security engineers, and understanding why matters even though Kinde handles the hard part for you. Knowing the risk lets you answer your customer's security team with confidence rather than a shrug.&lt;/p&gt;

&lt;p&gt;In an SP-initiated flow, your side starts the conversation. Kinde generates a request with its own identifier before the user is ever sent to the IdP, and when the response comes back, Kinde can confirm that it matches a request it actually made. There is a built-in handshake. The response is expected, and anything unexpected can be discarded.&lt;/p&gt;

&lt;p&gt;IdP-initiated flow removes that handshake. The assertion arrives without any prior request from the Service Provider, which is why it is sometimes called an unsolicited response. That property is convenient for the user, but it opens a specific attack.&lt;/p&gt;

&lt;p&gt;If an attacker can capture a valid SAML assertion, perhaps from a shared machine or an intercepted session, they can replay it to the Service Provider and potentially sign in as the original user, because there is no earlier request to check the assertion against.&lt;/p&gt;

&lt;p&gt;This class of issue is well documented, and it is the reason mature SAML implementations apply strict validation to unsolicited assertions rather than trusting them on arrival.&lt;/p&gt;

&lt;p&gt;For you as a developer building on Kinde, the architecture is what makes this manageable. Because Kinde is the Service Provider, the SAML assertion is posted to Kinde, not to your app. Kinde receives the unsolicited response, validates it, and only then issues your app its own session token.&lt;/p&gt;

&lt;p&gt;The replay protection, the assertion validation, the freshness and integrity checks, all of that lives inside Kinde's SAML handling. Your application never parses a raw assertion, never touches the SAML XML, and never has to implement these defenses itself. You receive a clean, validated Kinde session that looks exactly like any other login.&lt;/p&gt;

&lt;p&gt;&lt;a href="https://media2.dev.to/dynamic/image/width=800%2Cheight=%2Cfit=scale-down%2Cgravity=auto%2Cformat=auto/https%3A%2F%2Fdev-to-uploads.s3.us-east-2.amazonaws.com%2Fuploads%2Farticles%2Fswnbfc669rs5z8y5ipfw.png" class="article-body-image-wrapper"&gt;&lt;img src="https://media2.dev.to/dynamic/image/width=800%2Cheight=%2Cfit=scale-down%2Cgravity=auto%2Cformat=auto/https%3A%2F%2Fdev-to-uploads.s3.us-east-2.amazonaws.com%2Fuploads%2Farticles%2Fswnbfc669rs5z8y5ipfw.png" alt="Kinde sits between the IdP and your app as the Service Provider. The unsolicited assertion is validated at the Kinde layer, so your app only ever sees a trusted session." width="800" height="187"&gt;&lt;/a&gt;&lt;/p&gt;

&lt;p&gt;This is the single most important architectural point in this article. The reason this integration is mostly configuration rather than code is that Kinde absorbs the part that is genuinely dangerous to get wrong.&lt;/p&gt;
&lt;h2&gt;
  
  
  Before you start: how Kinde models enterprise customers
&lt;/h2&gt;

&lt;p&gt;A quick mental model will make the configuration steps make sense. In Kinde, each enterprise customer maps to a Kinde &lt;strong&gt;organization&lt;/strong&gt;, and enterprise connections live on those organizations. The customer brings their own identity provider, Kinde acts as the Service Provider in front of it, and your application always talks to Kinde rather than to the customer's IdP directly.&lt;/p&gt;

&lt;p&gt;You can attach a SAML connection at the environment level, meaning it is available across your business, or scope it to a single organization, meaning only that one customer uses it. For a typical B2B setup where each enterprise has its own Okta or Entra tenant, you will usually create the connection on that customer's organization.&lt;/p&gt;

&lt;p&gt;You will be working with a handful of values during setup, and it helps to know them by name before they appear on screen:&lt;/p&gt;

&lt;ul&gt;
&lt;li&gt;
&lt;strong&gt;ACS URL&lt;/strong&gt; (Assertion Consumer Service URL). This is Kinde's endpoint that receives the SAML response. You copy it from Kinde and give it to the customer's IT admin.&lt;/li&gt;
&lt;li&gt;
&lt;strong&gt;Entity ID&lt;/strong&gt;. A shared identifier that must match exactly on both sides. In various consoles it appears as Entity ID, SP Entity ID, Audience, or &lt;code&gt;entityID&lt;/code&gt;.&lt;/li&gt;
&lt;li&gt;
&lt;strong&gt;IdP metadata URL&lt;/strong&gt;. A publicly reachable URL hosting the IdP's SAML metadata XML, which Kinde reads to stay current on the IdP's signing certificate.&lt;/li&gt;
&lt;li&gt;
&lt;strong&gt;Name ID format&lt;/strong&gt; and &lt;strong&gt;key attributes&lt;/strong&gt;. These describe how the user's identifier and details, such as email, first name, and last name, are carried in the assertion.&lt;/li&gt;
&lt;/ul&gt;

&lt;p&gt;With those in hand, the setup is a matter of configuring two sides and making sure the shared values line up.&lt;/p&gt;
&lt;h2&gt;
  
  
  Step 1: Create or open the SAML enterprise connection in Kinde
&lt;/h2&gt;

&lt;p&gt;Inside the &lt;a href="https://kinde.com/?utm_source=devto&amp;amp;utm_medium=content&amp;amp;utm_campaign=shola&amp;amp;campaignid=chatgptapp&amp;amp;network=&amp;amp;adgroup=&amp;amp;keyword=&amp;amp;matchtype=&amp;amp;creative=17&amp;amp;device=&amp;amp;adposition=" rel="noopener noreferrer"&gt;Kinde dashboard&lt;/a&gt;, you reach a connection either through the customer's organization, by going to &lt;strong&gt;Organizations&lt;/strong&gt;, opening the organization, and selecting &lt;strong&gt;Authentication&lt;/strong&gt;, or at the environment level through &lt;strong&gt;Settings&lt;/strong&gt; then &lt;strong&gt;Authentication&lt;/strong&gt;. From there, in the enterprise connection section, you add a new enterprise connection and choose SAML, or open an existing SAML connection if you have already created one.&lt;/p&gt;

&lt;p&gt;&lt;a href="https://media2.dev.to/dynamic/image/width=800%2Cheight=%2Cfit=scale-down%2Cgravity=auto%2Cformat=auto/https%3A%2F%2Fdev-to-uploads.s3.us-east-2.amazonaws.com%2Fuploads%2Farticles%2Fwb649whlqwia82k3stwu.png" class="article-body-image-wrapper"&gt;&lt;img src="https://media2.dev.to/dynamic/image/width=800%2Cheight=%2Cfit=scale-down%2Cgravity=auto%2Cformat=auto/https%3A%2F%2Fdev-to-uploads.s3.us-east-2.amazonaws.com%2Fuploads%2Farticles%2Fwb649whlqwia82k3stwu.png" alt="The " width="800" height="502"&gt;&lt;/a&gt;&lt;/p&gt;

&lt;p&gt;Give the connection a clear name, since this name becomes the label on the sign-in button your users may see. Once the connection exists, you configure its core SAML fields, which is the next step.&lt;/p&gt;
&lt;h2&gt;
  
  
  Step 2: Configure the core SAML fields
&lt;/h2&gt;

&lt;p&gt;These fields define how Kinde and the customer's IdP understand each other. Most of them have sensible behavior, and you only need to be precise where the customer's IdP demands a specific value.&lt;/p&gt;

&lt;p&gt;&lt;strong&gt;Name ID format.&lt;/strong&gt; The Name ID is the element in the SAML assertion that uniquely identifies the user. The format you choose in Kinde must be one that the customer's IdP supports, so this is a value you confirm with their IT admin rather than guess.&lt;/p&gt;

&lt;p&gt;&lt;strong&gt;Email key attribute.&lt;/strong&gt; This tells Kinde which attribute in the SAML token carries the user's email. Setting it ensures the email comes through correctly, which matters both for identifying the user and for the friction-reducing behavior described later. Kinde recommends against leaving this blank, and will fall back to &lt;code&gt;email&lt;/code&gt; if you do.&lt;/p&gt;

&lt;p&gt;&lt;strong&gt;First name and last name key attributes.&lt;/strong&gt; Optional, used for personalization and for mapping the user into your system cleanly.&lt;/p&gt;

&lt;p&gt;&lt;strong&gt;Sign request algorithm.&lt;/strong&gt; This is the cryptographic algorithm used to sign SAML requests. RSA-SHA256 is the secure, commonly used choice. RSA-SHA1 is older and widely deprecated, so you only select it if a particular IdP still requires it.&lt;/p&gt;

&lt;p&gt;&lt;strong&gt;Protocol binding.&lt;/strong&gt; This is the transport used to send the SAML request. HTTP POST sends it via an HTML form and handles larger, signed payloads, which is the common choice. HTTP Redirect sends it as a URL parameter and is lighter but size-limited. You match whatever the customer's IdP supports.&lt;/p&gt;

&lt;p&gt;&lt;a href="https://media2.dev.to/dynamic/image/width=800%2Cheight=%2Cfit=scale-down%2Cgravity=auto%2Cformat=auto/https%3A%2F%2Fdev-to-uploads.s3.us-east-2.amazonaws.com%2Fuploads%2Farticles%2Fcbzvpzsdt6mku9svv2cm.png" class="article-body-image-wrapper"&gt;&lt;img src="https://media2.dev.to/dynamic/image/width=800%2Cheight=%2Cfit=scale-down%2Cgravity=auto%2Cformat=auto/https%3A%2F%2Fdev-to-uploads.s3.us-east-2.amazonaws.com%2Fuploads%2Farticles%2Fcbzvpzsdt6mku9svv2cm.png" alt="The " width="800" height="502"&gt;&lt;/a&gt;&lt;/p&gt;

&lt;p&gt;If you want a deeper reference for any of these, Kinde's advanced SAML configuration documentation covers each field in detail, but for most setups the defaults plus the customer's stated requirements are all you need.&lt;/p&gt;
&lt;h2&gt;
  
  
  Step 3: Turn on IdP-initiated SSO
&lt;/h2&gt;

&lt;p&gt;Enabling IdP-initiated SSO is a single toggle. Kinde shipped support for it in February 2026. On the SAML connection, scroll to the &lt;strong&gt;IdP-initiated SSO behavior&lt;/strong&gt; section and enable &lt;strong&gt;Accept requests&lt;/strong&gt;.&lt;/p&gt;

&lt;p&gt;The helper text spells out exactly what this does: it tells Kinde to accept unsolicited login requests sent to it by the identity provider, which is what allows the Kinde login page to be bypassed entirely when a user clicks the tile in their portal.&lt;/p&gt;

&lt;p&gt;&lt;a href="https://media2.dev.to/dynamic/image/width=800%2Cheight=%2Cfit=scale-down%2Cgravity=auto%2Cformat=auto/https%3A%2F%2Fdev-to-uploads.s3.us-east-2.amazonaws.com%2Fuploads%2Farticles%2Fsyg7adhjn6erue7m05zz.png" class="article-body-image-wrapper"&gt;&lt;img src="https://media2.dev.to/dynamic/image/width=800%2Cheight=%2Cfit=scale-down%2Cgravity=auto%2Cformat=auto/https%3A%2F%2Fdev-to-uploads.s3.us-east-2.amazonaws.com%2Fuploads%2Farticles%2Fsyg7adhjn6erue7m05zz.png" alt="The " width="800" height="502"&gt;&lt;/a&gt;&lt;/p&gt;

&lt;p&gt;One detail here tells Kinde where to send the user after the unsolicited login lands, and getting it right is essential.&lt;/p&gt;

&lt;p&gt;&lt;a href="https://kinde.com/?utm_source=devto&amp;amp;utm_medium=content&amp;amp;utm_campaign=shola&amp;amp;campaignid=chatgptapp&amp;amp;network=&amp;amp;adgroup=&amp;amp;keyword=&amp;amp;matchtype=&amp;amp;creative=17&amp;amp;device=&amp;amp;adposition=" rel="noopener noreferrer"&gt;Kinde&lt;/a&gt; asks you to pass a &lt;code&gt;relayState&lt;/code&gt; value in the IdP-initiated request, and that value is a target redirect URI carrying two parameters, &lt;code&gt;client_id&lt;/code&gt; and &lt;code&gt;connection_id&lt;/code&gt;. The &lt;code&gt;client_id&lt;/code&gt; identifies which of your Kinde applications the user is signing in to, and the &lt;code&gt;connection_id&lt;/code&gt; identifies which enterprise connection the assertion belongs to.&lt;/p&gt;

&lt;p&gt;You configure this &lt;code&gt;relayState&lt;/code&gt; on the identity provider side, in the app you set up there, so that every IdP-initiated login arrives pointed at the right application and connection. Without it, Kinde receives a valid assertion but has no instruction on where to take the user next.&lt;/p&gt;

&lt;p&gt;Directly beneath the toggle is a second option, Use IdP email as login hint. Switching it on tells Kinde to carry the email from the SAML response into the &lt;code&gt;login_hint&lt;/code&gt; query parameter, which lets the IdP skip its own account-selection screen when the user happens to be signed into more than one account. It is a small touch, but it removes a click for users who live in multiple Google or Microsoft accounts at once.&lt;/p&gt;

&lt;p&gt;Two related options matter for a B2B setup. &lt;strong&gt;Trusting email addresses&lt;/strong&gt; from the IdP lets Kinde match an incoming user to an existing account with the same email, rather than creating a duplicate identity, which makes for a more seamless sign-in. &lt;strong&gt;Single logout&lt;/strong&gt;, if switched on, means that signing out of your app also signs the user out of the other apps on that connection, which some enterprise customers expect.&lt;/p&gt;
&lt;h2&gt;
  
  
  Step 4: Hand the ACS URL and Entity ID to your customer
&lt;/h2&gt;

&lt;p&gt;With the Kinde side configured, copy two values to send to the customer's IT admin: the &lt;strong&gt;ACS URL&lt;/strong&gt; and the &lt;strong&gt;Entity ID&lt;/strong&gt;. These are the bridge between the two systems.&lt;/p&gt;

&lt;p&gt;The ACS URL is where the IdP will post its SAML response, and it points at Kinde. The Entity ID is the shared identifier that has to match precisely on both sides. If you use a custom domain in Kinde, copy the custom domain version of the ACS URL instead of the default.&lt;/p&gt;

&lt;p&gt;The Entity ID is the value most likely to cause a silent failure, because it shows up under different names in different consoles. In Kinde it is the Entity ID field. In the customer's IdP it might be labeled Audience, SP Entity ID, or simply Entity ID. If the strings do not match exactly, sign-in fails, often with an unhelpful invalid SAML response error.&lt;/p&gt;

&lt;p&gt;So when you send these values, say explicitly that the Entity ID must be entered character for character on their side.&lt;/p&gt;

&lt;blockquote&gt;
&lt;p&gt;&lt;strong&gt;What to send your customer's IT admin&lt;/strong&gt;&lt;/p&gt;

&lt;ol&gt;
&lt;li&gt;ACS URL, copied from Kinde&lt;/li&gt;
&lt;li&gt;Entity ID, which must match character for character on both sides&lt;/li&gt;
&lt;li&gt;The Name ID format you configured&lt;/li&gt;
&lt;li&gt;A request for their IdP metadata URL in return&lt;/li&gt;
&lt;/ol&gt;
&lt;/blockquote&gt;

&lt;p&gt;In return, you need one thing from them: the &lt;strong&gt;IdP metadata URL&lt;/strong&gt;, a publicly reachable link to their SAML metadata XML. Kinde reads this URL so that the IdP's signing certificate stays current automatically.&lt;/p&gt;

&lt;p&gt;One practical note, since it trips people up: some providers, Google Workspace among them, do not host the metadata file at a stable public URL, so the customer may need to host it somewhere reachable such as an S3 bucket, Cloudflare R2, or a public site, and give you that link.&lt;/p&gt;
&lt;h2&gt;
  
  
  Step 5: Set up the app tile on the IdP side
&lt;/h2&gt;

&lt;p&gt;This is the half of the work that lives in the customer's identity provider, and it is the part that creates the tile. The exact clicks differ per provider, so here are the three most common, at the level of detail your customer's admin will recognize. Including them serves readers who are configuring both sides themselves, which many smaller enterprise admins do.&lt;/p&gt;
&lt;h3&gt;
  
  
  Okta
&lt;/h3&gt;

&lt;p&gt;In the Okta admin console, the admin creates a new SAML application, enters the ACS URL and Entity ID from Kinde into the SAML settings, and configures the attribute statements so that email and name are passed through under the keys Kinde expects. Once the app is created and assigned to the relevant users or groups, it appears as a tile on those users' Okta dashboards.&lt;/p&gt;
&lt;h3&gt;
  
  
  Microsoft Entra ID
&lt;/h3&gt;

&lt;p&gt;In the Entra admin center the admin goes to &lt;strong&gt;Enterprise applications&lt;/strong&gt;, selects &lt;strong&gt;New application&lt;/strong&gt;, then &lt;strong&gt;Create your own application&lt;/strong&gt;, and chooses the option to integrate a non-gallery application. On the resulting app they open the single sign-on tile, select SAML, and edit the basic SAML configuration to add the Entity ID and ACS URL from Kinde. Entra carries email and name through claim attributes that use the &lt;code&gt;schemas.xmlsoap.org&lt;/code&gt; claim URLs, and the admin copies the App federation metadata URL back to you as the IdP metadata URL.&lt;/p&gt;
&lt;h3&gt;
  
  
  Google Workspace
&lt;/h3&gt;

&lt;p&gt;In the Google Admin console the admin goes to &lt;strong&gt;Apps&lt;/strong&gt;, then &lt;strong&gt;Web and Mobile Apps&lt;/strong&gt;, and chooses &lt;strong&gt;Add custom SAML app&lt;/strong&gt;. After naming the app, Google shows its own identity provider details and a DOWNLOAD METADATA button, and because Google does not host that metadata XML at a stable public URL, the admin downloads it and uploads it somewhere reachable, such as a Cloudflare R2 bucket or a public file host, then provides that link as the IdP metadata URL.&lt;/p&gt;

&lt;p&gt;On the Service provider details step the admin enters the ACS URL and the matching Entity ID from Kinde, sets the Name ID format to EMAIL, and sets the Name ID to Basic Information then Primary email. On the Attribute mapping step they map Primary email to the email key attribute Kinde expects, optionally adding first and last name, then finish and turn the app on for the chosen users so the tile appears.&lt;/p&gt;

&lt;p&gt;&lt;a href="https://media2.dev.to/dynamic/image/width=800%2Cheight=%2Cfit=scale-down%2Cgravity=auto%2Cformat=auto/https%3A%2F%2Fdev-to-uploads.s3.us-east-2.amazonaws.com%2Fuploads%2Farticles%2F6eijo3fwfkg56sdaozid.png" class="article-body-image-wrapper"&gt;&lt;img src="https://media2.dev.to/dynamic/image/width=800%2Cheight=%2Cfit=scale-down%2Cgravity=auto%2Cformat=auto/https%3A%2F%2Fdev-to-uploads.s3.us-east-2.amazonaws.com%2Fuploads%2Farticles%2F6eijo3fwfkg56sdaozid.png" alt="The DevRel Studio app detail page in Google Admin showing User access ON, Service provider details (ACS URL + Entity ID devrelstudio-gw-saml), and the SAML attribute mapping (email, first_name, last_name)" width="800" height="502"&gt;&lt;/a&gt;&lt;/p&gt;

&lt;p&gt;Across all three, the shape is identical even though the labels move around: enter Kinde's ACS URL and Entity ID, map the attributes, hand back the metadata URL, and assign the app to users so the tile appears.&lt;/p&gt;
&lt;h2&gt;
  
  
  Step 6: Test both directions
&lt;/h2&gt;

&lt;p&gt;Once both sides are configured and the connection is switched on, test the SP-initiated direction first, because it is the simpler check and it confirms the connection itself is sound. Open your app, trigger the enterprise sign-in, authenticate at the IdP, and confirm you land back in your app with a valid session. If you configured a home realm domain, the user is routed silently to the IdP. If not, they see a sign-in button labeled with your connection name.&lt;/p&gt;

&lt;p&gt;Next, test the IdP-initiated direction. Go to the IdP portal as an assigned user, click your app's tile, and confirm you arrive in your app already authenticated, with no detour through a login screen. That is the IdP-initiated flow working end to end, and it is the experience your enterprise customer's employees will have every day.&lt;/p&gt;

&lt;p&gt;A free Okta developer account or a test Entra tenant is enough to rehearse the whole thing before you involve a real customer, so that when a customer does ask, you can walk them through it from memory.&lt;/p&gt;

&lt;p&gt;&lt;a href="https://media2.dev.to/dynamic/image/width=800%2Cheight=%2Cfit=scale-down%2Cgravity=auto%2Cformat=auto/https%3A%2F%2Fdev-to-uploads.s3.us-east-2.amazonaws.com%2Fuploads%2Farticles%2F0grcdk9i20ohxymwmowx.png" class="article-body-image-wrapper"&gt;&lt;img src="https://media2.dev.to/dynamic/image/width=800%2Cheight=%2Cfit=scale-down%2Cgravity=auto%2Cformat=auto/https%3A%2F%2Fdev-to-uploads.s3.us-east-2.amazonaws.com%2Fuploads%2Farticles%2F0grcdk9i20ohxymwmowx.png" alt="A simple two-row " start="" width="800" height="87"&gt;&lt;/a&gt;&lt;/p&gt;

&lt;p&gt;If something fails, the usual culprit is the Entity ID not matching exactly between Kinde and the IdP, followed by an unreachable or stale metadata URL. Both produce sign-in errors that look more mysterious than they are, so they are the first two things to recheck.&lt;/p&gt;
&lt;h2&gt;
  
  
  What your app code has to change
&lt;/h2&gt;

&lt;p&gt;Almost nothing changes, and that is the whole point. Because Kinde is the Service Provider and absorbs the SAML handling, your application keeps talking to Kinde exactly as it already does. A user who arrives through the IdP-initiated flow shows up in your app as an authenticated Kinde session, indistinguishable in your code from a user who logged in any other way. There is no SAML parsing to write, no assertion to validate, no new endpoint to stand up on your side. The work you just did was configuration and coordination, which is precisely why this is something you can deliver to a customer quickly rather than scheduling a sprint for it.&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight typescript"&gt;&lt;code&gt;&lt;span class="c1"&gt;// Your existing Kinde session check is unchanged.&lt;/span&gt;
&lt;span class="c1"&gt;// An IdP-initiated user arrives as a normal authenticated session.&lt;/span&gt;
&lt;span class="k"&gt;import&lt;/span&gt; &lt;span class="p"&gt;{&lt;/span&gt; &lt;span class="nx"&gt;getKindeServerSession&lt;/span&gt; &lt;span class="p"&gt;}&lt;/span&gt; &lt;span class="k"&gt;from&lt;/span&gt; &lt;span class="dl"&gt;"&lt;/span&gt;&lt;span class="s2"&gt;@kinde-oss/kinde-auth-nextjs/server&lt;/span&gt;&lt;span class="dl"&gt;"&lt;/span&gt;&lt;span class="p"&gt;;&lt;/span&gt;

&lt;span class="kd"&gt;const&lt;/span&gt; &lt;span class="p"&gt;{&lt;/span&gt; &lt;span class="nx"&gt;isAuthenticated&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="nx"&gt;getUser&lt;/span&gt; &lt;span class="p"&gt;}&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="nf"&gt;getKindeServerSession&lt;/span&gt;&lt;span class="p"&gt;();&lt;/span&gt;

&lt;span class="k"&gt;if &lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="k"&gt;await&lt;/span&gt; &lt;span class="nf"&gt;isAuthenticated&lt;/span&gt;&lt;span class="p"&gt;())&lt;/span&gt; &lt;span class="p"&gt;{&lt;/span&gt;
  &lt;span class="kd"&gt;const&lt;/span&gt; &lt;span class="nx"&gt;user&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="k"&gt;await&lt;/span&gt; &lt;span class="nf"&gt;getUser&lt;/span&gt;&lt;span class="p"&gt;();&lt;/span&gt;
  &lt;span class="c1"&gt;// Same code path for every login method, IdP-initiated included.&lt;/span&gt;
&lt;span class="p"&gt;}&lt;/span&gt;
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;blockquote&gt;
&lt;p&gt;The snippet above is illustrative of how little changes. The exact Kinde SDK call depends on your framework, and Kinde provides SDKs for React, Next.js, Node/Express, .NET, Flutter, Java, SvelteKit, Remix, Go, and Expo.&lt;/p&gt;
&lt;/blockquote&gt;

&lt;h2&gt;
  
  
  Wrapping up
&lt;/h2&gt;

&lt;p&gt;The email that started this article, the one asking you to add a tile to a customer's portal, is no longer a mystery or a blocker. It is a request for IdP-initiated SSO, a flow where the identity provider starts the login and the assertion arrives unsolicited. Enterprises insist on it both for how their employees actually work and for the governance requirements that decide whether you pass a security review. The replay risk makes the flow sensitive, and Kinde, sitting in front of your app as the Service Provider, validates the unsolicited assertion so your code never has to.&lt;/p&gt;

&lt;p&gt;The work is configuration rather than construction. Enable the Accept requests toggle on the Kinde connection, set the &lt;code&gt;relayState&lt;/code&gt; with your &lt;code&gt;client_id&lt;/code&gt; and &lt;code&gt;connection_id&lt;/code&gt; on the identity provider side, exchange the ACS URL, Entity ID, and metadata URL with your customer's IT admin, set up the matching app on their Okta, Entra, or Google Workspace, and test both directions. Your customer's employees get a tile that drops them straight into your app, their IT team gets an application that behaves inside their identity governance, and the deal that was waiting on this keeps moving.&lt;/p&gt;

&lt;p&gt;Kinde is free for up to 10,500 monthly active users, with no credit card required to start. You can create an account and configure your first enterprise connection at &lt;a href="https://kinde.com/?utm_source=devto&amp;amp;utm_medium=content&amp;amp;utm_campaign=shola&amp;amp;campaignid=chatgptapp&amp;amp;network=&amp;amp;adgroup=&amp;amp;keyword=&amp;amp;matchtype=&amp;amp;creative=17&amp;amp;device=&amp;amp;adposition=" rel="noopener noreferrer"&gt;kinde.com&lt;/a&gt;, and the enterprise connections documentation walks through each provider in full detail.&lt;/p&gt;

&lt;p&gt;&lt;em&gt;Building something with Kinde and enterprise auth? I write about identity, billing, and agent infrastructure for developers. Follow along for more walkthroughs like this one.&lt;/em&gt;&lt;/p&gt;

</description>
      <category>kinde</category>
      <category>webdev</category>
      <category>tutorial</category>
      <category>learning</category>
    </item>
  </channel>
</rss>
