<?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 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>
    <item>
      <title>How to Send Auth Codes via WhatsApp in Your App With Kinde</title>
      <dc:creator>Shola Jegede</dc:creator>
      <pubDate>Fri, 15 May 2026 00:38:16 +0000</pubDate>
      <link>https://dev.to/sholajegede/how-to-send-auth-codes-via-whatsapp-in-your-app-with-kinde-6k9</link>
      <guid>https://dev.to/sholajegede/how-to-send-auth-codes-via-whatsapp-in-your-app-with-kinde-6k9</guid>
      <description>&lt;p&gt;Your users are in San Francisco, Jakarta, São Paulo, and Sydney. They have WhatsApp open all day. They check it before they check their SMS inbox. When your app sends an OTP to their phone number, it lands in a carrier SMS thread they barely look at. When it lands in WhatsApp, they see it instantly.&lt;/p&gt;

&lt;p&gt;In this article, you will learn:&lt;/p&gt;

&lt;ul&gt;
&lt;li&gt;Why WhatsApp OTP delivers better completion rates than SMS in high-penetration markets&lt;/li&gt;
&lt;li&gt;How Kinde delivers phone OTPs via WhatsApp when configured, with automatic SMS fallback&lt;/li&gt;
&lt;li&gt;What you need before you start: Twilio account, WhatsApp Sender, Kinde Pro&lt;/li&gt;
&lt;li&gt;How to configure Twilio Verify with a WhatsApp Sender&lt;/li&gt;
&lt;li&gt;How to connect your Twilio account to Kinde&lt;/li&gt;
&lt;li&gt;How to enable phone authentication in your Next.js app&lt;/li&gt;
&lt;li&gt;How to enable WhatsApp as the delivery channel in Kinde&lt;/li&gt;
&lt;li&gt;How to configure SMS as the automatic fallback&lt;/li&gt;
&lt;li&gt;How to test the full flow before going live&lt;/li&gt;
&lt;li&gt;What to watch for in Africa, South Asia, and Southeast Asia specifically&lt;/li&gt;
&lt;/ul&gt;

&lt;p&gt;Let's dive in!&lt;/p&gt;

&lt;h2&gt;
  
  
  Why WhatsApp OTP Is the Right Choice for Your Market
&lt;/h2&gt;

&lt;p&gt;If your users are in Europe or North America, SMS OTP is a reasonable default. Delivery is reliable, carrier infrastructure is solid, and most users check texts promptly.&lt;/p&gt;

&lt;p&gt;If your users are in Nigeria, Kenya, Indonesia, Brazil, India, or the Philippines, the calculation is different. WhatsApp penetration in these markets is near-universal. Users across sub-Saharan Africa and Southeast Asia treat WhatsApp as their primary communication channel. SMS sits behind it, sometimes significantly behind.&lt;/p&gt;

&lt;p&gt;The data on this is clear. Twilio has documented that in heavy WhatsApp countries, 20 to 40 percent of users pick the WhatsApp channel when given the option for OTP delivery. More importantly, WhatsApp OTPs arrive over Wi-Fi connections. A user whose cellular data signal is weak but who is on Wi-Fi still receives the WhatsApp message instantly. SMS requires telephony connectivity. In markets where cellular data coverage is more reliable than SMS delivery, WhatsApp is not just more convenient. It is more reliable.&lt;/p&gt;

&lt;p&gt;There is also a cost argument. WhatsApp authentication messages cost 50 to 99 percent less than SMS in most markets globally. In developing markets where SMS costs are highest, the savings are largest. At any meaningful scale, the economics strongly favor WhatsApp.&lt;/p&gt;

&lt;p&gt;The correct production setup is WhatsApp first with automatic SMS fallback for the minority of users who do not have WhatsApp. That is exactly what Kinde gives you.&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.amazonaws.com%2Fuploads%2Farticles%2Frb16f82rql7whjv3c6m8.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.amazonaws.com%2Fuploads%2Farticles%2Frb16f82rql7whjv3c6m8.png" alt="Two-column comparison. LEFT: SMS OTP delivery flow showing Phone number → Carrier network → SMS inbox (variable delivery time, carrier dependent, fails on weak signal). RIGHT: WhatsApp OTP delivery flow showing Phone number → WhatsApp Business API → WhatsApp inbox (instant, Wi-Fi compatible, end-to-end encrypted, automatic SMS fallback if WhatsApp unavailable)" width="800" height="565"&gt;&lt;/a&gt;&lt;/p&gt;

&lt;h2&gt;
  
  
  How Kinde Handles WhatsApp OTP
&lt;/h2&gt;

&lt;p&gt;Kinde supports phone number as both a primary authentication method and a secondary MFA factor. When phone authentication is configured, Kinde uses Twilio to deliver OTP codes. When you configure a WhatsApp Sender in your Twilio account and connect it to Kinde, Kinde automatically prefers WhatsApp for delivery and falls back to SMS when WhatsApp is unavailable.&lt;/p&gt;

&lt;p&gt;This is Twilio's Verify WhatsApp feature working through Kinde's phone authentication layer. You do not write any message-routing logic. Kinde and Twilio handle it. When a user enters their phone number on your sign-in screen, the OTP arrives in their WhatsApp. If they do not have WhatsApp or the delivery fails, Twilio's Verify API automatically retries via SMS.&lt;/p&gt;

&lt;p&gt;There are a few important constraints to know before you start:&lt;/p&gt;

&lt;p&gt;&lt;strong&gt;Kinde Pro is required for production phone authentication.&lt;/strong&gt; Kinde gives you 10 free SMS test sends per month on any plan to experiment with the feature, but production phone authentication (including WhatsApp) requires upgrading to the Pro plan or above.&lt;/p&gt;

&lt;p&gt;&lt;strong&gt;The OTP message format is not editable.&lt;/strong&gt; Kinde uses a standardized SMS/WhatsApp template that complies with OTP best practices. The message arrives as: 123456 is your verification code. followed by For your security, do not share this code. No app name, no URL, no hash. The sender name shown at the top of the WhatsApp chat comes from your registered Twilio business display name, not the message body. You cannot customize the message text or format.&lt;/p&gt;

&lt;p&gt;&lt;strong&gt;A Twilio business account is required.&lt;/strong&gt; You cannot use a personal or trial Twilio account for production A2P (Application-to-Person) messaging. You need a Twilio business account with a configured phone number or Messaging Service.&lt;/p&gt;

&lt;p&gt;&lt;strong&gt;10DLC registration may be required in the US.&lt;/strong&gt; If your users are in the United States, Twilio requires 10DLC registration for SMS. Check Twilio's guidelines and A2P messaging requirements for your target countries before setting up.&lt;/p&gt;

&lt;h2&gt;
  
  
  What You Need Before You Start
&lt;/h2&gt;

&lt;p&gt;Before touching the Kinde dashboard, have the following ready:&lt;/p&gt;

&lt;p&gt;&lt;strong&gt;A Twilio business account&lt;/strong&gt; with your Account SID and Auth Token from the Twilio Console.&lt;/p&gt;

&lt;p&gt;&lt;strong&gt;A Twilio phone number or Messaging Service SID.&lt;/strong&gt; Twilio gives you two options for identifying the sender: a specific phone number or a Messaging Service (a pool of numbers Twilio manages for deliverability). The Messaging Service is recommended for production because Twilio handles number rotation and deliverability automatically.&lt;/p&gt;

&lt;p&gt;&lt;strong&gt;A WhatsApp Sender configured in Twilio.&lt;/strong&gt; This is a WhatsApp Business Account (WABA) phone number registered with Meta and approved for authentication message templates. Twilio's Verify WhatsApp documentation walks through this process. You need Meta Business verification and an approved authentication template before WhatsApp delivery works.&lt;/p&gt;

&lt;p&gt;&lt;strong&gt;A Kinde account on the Pro plan or above.&lt;/strong&gt;&lt;/p&gt;

&lt;p&gt;&lt;strong&gt;A Next.js app with the Kinde SDK installed.&lt;/strong&gt; If you are starting fresh, the Kinde Next.js quickstart at &lt;a href="https://docs.kinde.com" rel="noopener noreferrer"&gt;docs.kinde.com&lt;/a&gt; gets you to a working auth setup in under ten minutes.&lt;/p&gt;

&lt;p&gt;Note: You can complete the Kinde configuration and test with the 10 free SMS sends before completing the WhatsApp Sender setup in Twilio. This lets you verify the Kinde connection is working before adding WhatsApp to the mix.&lt;/p&gt;

&lt;h2&gt;
  
  
  Step #1: Configure Twilio for Phone and WhatsApp Delivery
&lt;/h2&gt;

&lt;p&gt;Log in to your Twilio Console at &lt;a href="https://console.twilio.com" rel="noopener noreferrer"&gt;console.twilio.com&lt;/a&gt;.&lt;/p&gt;

&lt;p&gt;&lt;strong&gt;Get your credentials.&lt;/strong&gt; From the Console Dashboard, copy your Account SID and Auth Token. Keep the Auth Token private.&lt;/p&gt;

&lt;p&gt;&lt;strong&gt;Set up a Messaging Service (recommended) or phone number.&lt;/strong&gt; If using a Messaging Service, navigate to &lt;strong&gt;Messaging → Services → Create Messaging Service&lt;/strong&gt;. Give it a name, add your sender number to it, and copy the Messaging Service SID.&lt;/p&gt;

&lt;p&gt;If using a specific Twilio phone number instead, navigate to &lt;strong&gt;Phone Numbers → Manage → Active numbers&lt;/strong&gt; and copy the phone number in E.164 format (e.g. &lt;code&gt;+12025551234&lt;/code&gt;).&lt;/p&gt;

&lt;p&gt;&lt;strong&gt;Configure the WhatsApp Sender for Verify.&lt;/strong&gt; This is the step that enables WhatsApp delivery. Navigate to &lt;strong&gt;Messaging → Senders → WhatsApp senders&lt;/strong&gt; in the Twilio Console.&lt;/p&gt;

&lt;p&gt;&lt;a href="https://media2.dev.to/dynamic/image/width=800%2Cheight=%2Cfit=scale-down%2Cgravity=auto/https%3A%2F%2Fdev-to-uploads.s3.amazonaws.com%2Fuploads%2Farticles%2F78y528rv9h693ly0nwi4.webp" class="article-body-image-wrapper"&gt;&lt;img src="https://media2.dev.to/dynamic/image/width=800%2Cheight=%2Cfit=scale-down%2Cgravity=auto/https%3A%2F%2Fdev-to-uploads.s3.amazonaws.com%2Fuploads%2Farticles%2F78y528rv9h693ly0nwi4.webp" alt="Twilio Console showing the Verify section in the left sidebar, with WhatsApp Senders highlighted" width="800" height="520"&gt;&lt;/a&gt;&lt;/p&gt;

&lt;p&gt;Select &lt;strong&gt;Add Sender&lt;/strong&gt;. You will be prompted to connect a WhatsApp Business Account. This requires:&lt;/p&gt;

&lt;ul&gt;
&lt;li&gt;A Meta Business Account (verified)&lt;/li&gt;
&lt;li&gt;A phone number registered as a WhatsApp Business number&lt;/li&gt;
&lt;li&gt;An approved WhatsApp authentication message template&lt;/li&gt;
&lt;/ul&gt;

&lt;p&gt;Twilio auto-creates the authentication template in supported languages once you complete the WhatsApp Sender setup. The template uses the Copy Code format, which displays the OTP with a button users tap to copy it to their clipboard.&lt;/p&gt;

&lt;p&gt;Note: WhatsApp Sender approval from Meta typically takes one to three business days. Plan your timeline accordingly. You can continue with the Kinde setup and test using SMS while waiting for WhatsApp approval.&lt;/p&gt;

&lt;h2&gt;
  
  
  Step #2: Connect Twilio to Kinde
&lt;/h2&gt;

&lt;p&gt;With Twilio credentials ready, connect them to Kinde.&lt;/p&gt;

&lt;p&gt;In your Kinde dashboard, navigate to &lt;strong&gt;Settings → Environment → Phone providers&lt;/strong&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.amazonaws.com%2Fuploads%2Farticles%2F82l2xttxlh01kxc9fuqy.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.amazonaws.com%2Fuploads%2Farticles%2F82l2xttxlh01kxc9fuqy.png" alt="Kinde Settings showing the Phone providers section with a form to enter Twilio credentials. Fields visible: Account SID, Auth Token, and the choice between Messaging Service SID or Twilio Phone Number. Show the fields partially filled" width="800" height="502"&gt;&lt;/a&gt;&lt;/p&gt;

&lt;p&gt;Enter your Twilio credentials:&lt;/p&gt;

&lt;ul&gt;
&lt;li&gt;
&lt;strong&gt;Account SID&lt;/strong&gt;: from your Twilio Console dashboard&lt;/li&gt;
&lt;li&gt;
&lt;strong&gt;Auth Token&lt;/strong&gt;: from your Twilio Console dashboard&lt;/li&gt;
&lt;li&gt;
&lt;strong&gt;Messaging service SID or Twilio phone number&lt;/strong&gt;: enter whichever you set up in Step #1&lt;/li&gt;
&lt;/ul&gt;

&lt;p&gt;If you want to use a fallback in case the primary Twilio service is interrupted, enable the fallback service option and configure a secondary provider.&lt;/p&gt;

&lt;p&gt;Select &lt;strong&gt;Save&lt;/strong&gt;.&lt;/p&gt;

&lt;p&gt;Terrific! Kinde can now route phone OTP delivery through Twilio. The WhatsApp channel activates automatically once your WhatsApp Sender is approved in Twilio.&lt;/p&gt;

&lt;h2&gt;
  
  
  Step #3: Enable Phone Authentication in Kinde
&lt;/h2&gt;

&lt;p&gt;With Twilio connected, turn on phone as an authentication method for your application.&lt;/p&gt;

&lt;p&gt;Navigate to &lt;strong&gt;Settings → Environment → Authentication&lt;/strong&gt;. In the &lt;strong&gt;Passwordless&lt;/strong&gt; section, find the &lt;strong&gt;Phone&lt;/strong&gt; tile and select &lt;strong&gt;Configure&lt;/strong&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.amazonaws.com%2Fuploads%2Farticles%2F9ywuji2camdxln3odzu1.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.amazonaws.com%2Fuploads%2Farticles%2F9ywuji2camdxln3odzu1.png" alt="Kinde Settings &gt; Environment &gt; Authentication page showing the Passwordless section with Email + code, Phone, and Username + code tiles. The Phone tile has a " width="800" height="502"&gt;&lt;/a&gt;&lt;/p&gt;

&lt;p&gt;In the configuration window, toggle phone authentication on for the applications you want it active for. If you have multiple applications in your Kinde environment, you can enable it selectively per application.&lt;/p&gt;

&lt;p&gt;Select &lt;strong&gt;Save&lt;/strong&gt;.&lt;/p&gt;

&lt;p&gt;Your sign-in screen now includes a phone number input option alongside the existing email input. Users can choose to sign in with their phone number and receive an OTP instead of entering a password or email code.&lt;/p&gt;

&lt;h2&gt;
  
  
  Step #4: Enable WhatsApp as the Delivery Channel
&lt;/h2&gt;

&lt;p&gt;With phone authentication active and Twilio connected, the final step is confirming that WhatsApp is set as the preferred delivery channel.&lt;/p&gt;

&lt;p&gt;In your Kinde dashboard, navigate to &lt;strong&gt;Settings → Environment → Phone providers&lt;/strong&gt;. In your Twilio configuration, you will see the WhatsApp delivery option.&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.amazonaws.com%2Fuploads%2Farticles%2Fnitlv0mmfdu8wso5nne7.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.amazonaws.com%2Fuploads%2Farticles%2Fnitlv0mmfdu8wso5nne7.png" alt="Kinde Settings &gt; Phone providers showing the Twilio configuration with a WhatsApp section. Show the toggle or setting that enables WhatsApp as the preferred delivery channel, with SMS shown as the fallback" width="800" height="502"&gt;&lt;/a&gt;&lt;/p&gt;

&lt;p&gt;Enable the WhatsApp delivery option. Once enabled:&lt;/p&gt;

&lt;ul&gt;
&lt;li&gt;Kinde sends the OTP via WhatsApp when the user's phone number is registered with WhatsApp&lt;/li&gt;
&lt;li&gt;If WhatsApp delivery fails (user does not have WhatsApp, or the delivery fails for any reason), Twilio's Verify API automatically falls back to SMS&lt;/li&gt;
&lt;li&gt;No code changes are needed in your application&lt;/li&gt;
&lt;/ul&gt;

&lt;p&gt;The fallback happens silently. Your users do not see an error or need to request a retry. Twilio detects the WhatsApp delivery failure and retries via SMS automatically.&lt;/p&gt;

&lt;h2&gt;
  
  
  Step #5: Configure Phone Auth as MFA (Optional)
&lt;/h2&gt;

&lt;p&gt;If you want to use WhatsApp OTP as a second factor rather than (or in addition to) a primary authentication method, configure it at the MFA level.&lt;/p&gt;

&lt;p&gt;Navigate to &lt;strong&gt;Settings → Environment → Multi-factor auth&lt;/strong&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.amazonaws.com%2Fuploads%2Farticles%2F4vdkmuzniccn2vr6q4mc.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.amazonaws.com%2Fuploads%2Farticles%2F4vdkmuzniccn2vr6q4mc.png" alt="Kinde Settings &gt; Multi-factor auth page showing the " width="800" height="501"&gt;&lt;/a&gt;&lt;/p&gt;

&lt;p&gt;In the &lt;strong&gt;Additional authentication methods&lt;/strong&gt; section, toggle on &lt;strong&gt;SMS&lt;/strong&gt;. Per the Kinde docs, the SMS MFA option delivers via WhatsApp when configured, with automatic fallback to SMS. The WhatsApp preference follows from your Twilio configuration in Step #4 automatically.&lt;/p&gt;

&lt;p&gt;Set MFA to &lt;strong&gt;Yes&lt;/strong&gt; (mandatory) or &lt;strong&gt;Optional&lt;/strong&gt; depending on your product's security requirements:&lt;/p&gt;

&lt;ul&gt;
&lt;li&gt;
&lt;strong&gt;Yes&lt;/strong&gt;: every user is required to set up MFA on first sign-in. For products targeting enterprise customers or handling sensitive data, this is the right default.&lt;/li&gt;
&lt;li&gt;
&lt;strong&gt;Optional&lt;/strong&gt;: users are prompted to set up MFA but can skip it. They can enable it later from their profile settings.&lt;/li&gt;
&lt;/ul&gt;

&lt;p&gt;Note: if your primary authentication method is already phone OTP (email OTP), do not set phone SMS as the secondary MFA factor. Kinde recommends against using the same channel for both primary and secondary factors. If your primary auth is email OTP, use phone SMS or authenticator app for MFA. If your primary auth is phone OTP, use email or authenticator app for MFA.&lt;/p&gt;

&lt;p&gt;Select &lt;strong&gt;Save&lt;/strong&gt;.&lt;/p&gt;

&lt;h2&gt;
  
  
  Step #6: Wire Phone Authentication Into Your Next.js App
&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=14&amp;amp;device=&amp;amp;adposition=" rel="noopener noreferrer"&gt;Kinde's hosted sign-in&lt;/a&gt; screen already includes the phone number input once you enable phone authentication. Your Next.js app does not need code changes to show the phone sign-in option. It appears automatically on the Kinde-hosted auth pages.&lt;/p&gt;

&lt;p&gt;However, if you want to pre-fill the phone number for users coming from a known context (for example, users you have invited via phone number), use the &lt;code&gt;login_hint&lt;/code&gt; parameter to reduce friction:&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;// app/api/auth/[kindeAuth]/route.ts&lt;/span&gt;
&lt;span class="c1"&gt;// Standard Kinde auth setup — no changes needed for basic WhatsApp OTP&lt;/span&gt;

&lt;span class="c1"&gt;// For login with pre-filled phone number hint:&lt;/span&gt;
&lt;span class="c1"&gt;// Pass login_hint in the authorization URL when you know the user's phone number&lt;/span&gt;
&lt;span class="c1"&gt;// This skips the phone number entry step&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;handleAuth&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="k"&gt;export&lt;/span&gt; &lt;span class="kd"&gt;const&lt;/span&gt; &lt;span class="nx"&gt;GET&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="nf"&gt;handleAuth&lt;/span&gt;&lt;span class="p"&gt;();&lt;/span&gt;
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;For inviting users with a known phone number and pre-filling the sign-in form:&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight tsx"&gt;&lt;code&gt;&lt;span class="c1"&gt;// components/PhoneInviteButton.tsx&lt;/span&gt;
&lt;span class="c1"&gt;// Pre-fills the phone number on the Kinde sign-in screen&lt;/span&gt;
&lt;span class="c1"&gt;// Useful when you know the user's phone number before they authenticate&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;LoginLink&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/components&lt;/span&gt;&lt;span class="dl"&gt;"&lt;/span&gt;&lt;span class="p"&gt;;&lt;/span&gt;

&lt;span class="kr"&gt;interface&lt;/span&gt; &lt;span class="nx"&gt;PhoneInviteButtonProps&lt;/span&gt; &lt;span class="p"&gt;{&lt;/span&gt;
  &lt;span class="nl"&gt;phoneNumber&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;// E.164 format e.g. "+2348012345678"&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;PhoneInviteButton&lt;/span&gt;&lt;span class="p"&gt;({&lt;/span&gt; &lt;span class="nx"&gt;phoneNumber&lt;/span&gt; &lt;span class="p"&gt;}:&lt;/span&gt; &lt;span class="nx"&gt;PhoneInviteButtonProps&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="p"&gt;&amp;lt;&lt;/span&gt;&lt;span class="nc"&gt;LoginLink&lt;/span&gt;
      &lt;span class="na"&gt;authUrlParams&lt;/span&gt;&lt;span class="p"&gt;=&lt;/span&gt;&lt;span class="si"&gt;{&lt;/span&gt;&lt;span class="p"&gt;{&lt;/span&gt;
        &lt;span class="c1"&gt;// login_hint pre-fills the identity field on the Kinde sign-in screen&lt;/span&gt;
        &lt;span class="c1"&gt;// The user sees their phone number already entered and just confirms it&lt;/span&gt;
        &lt;span class="na"&gt;login_hint&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="nx"&gt;phoneNumber&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt;
        &lt;span class="c1"&gt;// connection_id routes directly to phone authentication&lt;/span&gt;
        &lt;span class="c1"&gt;// Skips the method selection screen&lt;/span&gt;
        &lt;span class="na"&gt;connection_id&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="dl"&gt;"&lt;/span&gt;&lt;span class="s2"&gt;phone&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="si"&gt;}&lt;/span&gt;
    &lt;span class="p"&gt;&amp;gt;&lt;/span&gt;
      Sign in with phone
    &lt;span class="p"&gt;&amp;lt;/&lt;/span&gt;&lt;span class="nc"&gt;LoginLink&lt;/span&gt;&lt;span class="p"&gt;&amp;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 protecting API routes on the server, the existing &lt;code&gt;getKindeServerSession&lt;/code&gt; approach works identically regardless of whether the user authenticated via email or phone:&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;// app/api/example/route.ts&lt;/span&gt;
&lt;span class="c1"&gt;// Phone-authenticated users produce the same session as email-authenticated users&lt;/span&gt;
&lt;span class="c1"&gt;// No changes needed to protected route logic&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="k"&gt;import&lt;/span&gt; &lt;span class="p"&gt;{&lt;/span&gt; &lt;span class="nx"&gt;NextResponse&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;next/server&lt;/span&gt;&lt;span class="dl"&gt;"&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;GET&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;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="o"&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;isAuthenticated&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="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="s2"&gt;Unauthorized&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;status&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="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;// user.phone is populated when the user authenticated via phone number&lt;/span&gt;
  &lt;span class="c1"&gt;// user.email is populated when the user authenticated via email&lt;/span&gt;
  &lt;span class="c1"&gt;// Both may be present if the user has linked both identities&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="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;span class="na"&gt;phone&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;phone&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt;
    &lt;span class="na"&gt;email&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;email&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;Amazing!&lt;/p&gt;

&lt;h2&gt;
  
  
  Step #7: Test the Full WhatsApp OTP Flow
&lt;/h2&gt;

&lt;p&gt;Before going live, test three scenarios in your Kinde non-production environment.&lt;/p&gt;

&lt;p&gt;&lt;strong&gt;Test 1: WhatsApp delivery to a WhatsApp-registered number&lt;/strong&gt;&lt;/p&gt;

&lt;p&gt;Enter a phone number that is registered with WhatsApp. Kinde should route the OTP through Twilio Verify to WhatsApp. The code arrives in your WhatsApp with your Twilio-registered business name as the sender.&lt;/p&gt;

&lt;p&gt;Note: Kinde provides 10 free SMS sends per month for testing. These 10 free sends apply to SMS delivery. WhatsApp delivery tests use your Twilio Verify credits.&lt;/p&gt;

&lt;p&gt;&lt;strong&gt;Test 2: Automatic SMS fallback&lt;/strong&gt;&lt;/p&gt;

&lt;p&gt;To test the fallback, enter a valid phone number that is not registered with WhatsApp, or temporarily disable WhatsApp in your Twilio account. Twilio Verify should detect the WhatsApp delivery failure and automatically send via SMS. The OTP code is the same in both channels.&lt;/p&gt;

&lt;p&gt;&lt;strong&gt;Test 3: MFA flow (if configured)&lt;/strong&gt;&lt;/p&gt;

&lt;p&gt;If you have set phone SMS as an MFA factor, sign in with email OTP first. On the second factor screen, Kinde prompts for the phone-based code. Enter your phone number if not already registered, and confirm the WhatsApp delivery.&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.amazonaws.com%2Fuploads%2Farticles%2F4tb6z7iwbxobnm73v6z9.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.amazonaws.com%2Fuploads%2Farticles%2F4tb6z7iwbxobnm73v6z9.png" alt="Kinde-hosted sign-in screen showing the phone number input field. Show a clean phone number entry UI with the country code dropdown and the number field. This is what users see when they choose phone authentication" width="799" height="501"&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/https%3A%2F%2Fdev-to-uploads.s3.amazonaws.com%2Fuploads%2Farticles%2F64934qlrvd9t0v31j2fh.webp" class="article-body-image-wrapper"&gt;&lt;img src="https://media2.dev.to/dynamic/image/width=800%2Cheight=%2Cfit=scale-down%2Cgravity=auto/https%3A%2F%2Fdev-to-uploads.s3.amazonaws.com%2Fuploads%2Farticles%2F64934qlrvd9t0v31j2fh.webp" alt="A WhatsApp message on a phone showing a Kinde OTP delivery. The message shows the standard format" width="800" height="775"&gt;&lt;/a&gt;&lt;/p&gt;

&lt;h2&gt;
  
  
  Country-Specific Notes for African and Southeast Asian Markets
&lt;/h2&gt;

&lt;p&gt;&lt;strong&gt;Nigeria and Ghana:&lt;/strong&gt; WhatsApp penetration is extremely high, above 90 percent of smartphone users. SMS delivery is inconsistent in some areas. WhatsApp OTP is strongly recommended as the primary channel. Twilio's A2P messaging in Nigeria requires pre-registered sender IDs. Check Twilio's Nigeria SMS guidelines before launch.&lt;/p&gt;

&lt;p&gt;&lt;strong&gt;Kenya, Tanzania, Uganda:&lt;/strong&gt; WhatsApp is widely used but M-Pesa and other mobile money services mean users are accustomed to SMS-based verification from financial services. WhatsApp OTP delivers a better experience but SMS fallback is particularly important in rural areas.&lt;/p&gt;

&lt;p&gt;&lt;strong&gt;Indonesia, Philippines, Vietnam:&lt;/strong&gt; Near-universal WhatsApp (and Line, in some markets) penetration. International SMS is expensive from most providers. WhatsApp OTP delivers significant cost savings at scale here.&lt;/p&gt;

&lt;p&gt;&lt;strong&gt;India:&lt;/strong&gt; WhatsApp has over 500 million users. However, Meta has implemented messaging limits for WhatsApp Business in India. Review the current Meta Business Platform limits for Indian traffic before projecting volume. Twilio's documentation covers the current limits.&lt;/p&gt;

&lt;p&gt;&lt;strong&gt;Brazil:&lt;/strong&gt; One of the highest WhatsApp penetration rates globally. WhatsApp OTP is effectively the standard for authentication flows built for Brazilian users.&lt;/p&gt;

&lt;p&gt;For all markets: always test with real numbers in the target country before launch. Carrier behavior, number formatting requirements (always use E.164 format), and delivery timing vary by region.&lt;/p&gt;

&lt;h2&gt;
  
  
  Troubleshooting Common Issues
&lt;/h2&gt;

&lt;p&gt;&lt;strong&gt;OTP not received via WhatsApp&lt;/strong&gt;&lt;/p&gt;

&lt;p&gt;Check that your WhatsApp Sender is approved in Twilio and that the authentication template is active. An unapproved or suspended sender silently fails delivery. Check Twilio's delivery logs for error codes. Error code 63024 from Twilio means the phone number is not associated with a WhatsApp account and the fallback to SMS should have triggered.&lt;/p&gt;

&lt;p&gt;&lt;strong&gt;OTP received via SMS instead of WhatsApp&lt;/strong&gt;&lt;/p&gt;

&lt;p&gt;This is the expected behavior when WhatsApp delivery is not available. If you expected WhatsApp delivery but got SMS, check: Is the destination phone number registered with WhatsApp? Is the WhatsApp Sender active and approved in Twilio? Has the user blocked your business sender in WhatsApp?&lt;/p&gt;

&lt;p&gt;&lt;strong&gt;"You need to enter your Twilio account details" message in Kinde&lt;/strong&gt;&lt;/p&gt;

&lt;p&gt;This appears when Kinde's phone authentication is configured but Twilio credentials have not been saved, or the credentials are invalid. Navigate to &lt;strong&gt;Settings → Environment → Phone providers&lt;/strong&gt; and re-enter your Twilio Account SID and Auth Token.&lt;/p&gt;

&lt;p&gt;&lt;strong&gt;Users in the United States not receiving SMS&lt;/strong&gt;&lt;/p&gt;

&lt;p&gt;US carriers require 10DLC registration for A2P SMS. If your Twilio account is not 10DLC registered for US traffic, messages to US numbers will fail. Complete 10DLC registration in Twilio before enabling phone authentication for US users.&lt;/p&gt;

&lt;p&gt;&lt;strong&gt;Phone number formatting errors&lt;/strong&gt;&lt;/p&gt;

&lt;p&gt;Kinde and Twilio both require phone numbers in E.164 format (e.g. &lt;code&gt;+2348012345678&lt;/code&gt; for a Nigerian number). If users enter numbers without country codes, the OTP send will fail. Kinde's hosted sign-in screen includes a country code dropdown to help users format numbers correctly. If you are pre-filling numbers via &lt;code&gt;login_hint&lt;/code&gt;, always format them in E.164.&lt;/p&gt;

&lt;h2&gt;
  
  
  Putting It All Together
&lt;/h2&gt;

&lt;p&gt;Here is a summary of the full setup:&lt;/p&gt;

&lt;div class="table-wrapper-paragraph"&gt;&lt;table&gt;
&lt;thead&gt;
&lt;tr&gt;
&lt;th&gt;Step&lt;/th&gt;
&lt;th&gt;What you do&lt;/th&gt;
&lt;th&gt;Where&lt;/th&gt;
&lt;/tr&gt;
&lt;/thead&gt;
&lt;tbody&gt;
&lt;tr&gt;
&lt;td&gt;Twilio business account&lt;/td&gt;
&lt;td&gt;Get Account SID, Auth Token, Messaging Service&lt;/td&gt;
&lt;td&gt;Twilio Console&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;WhatsApp Sender&lt;/td&gt;
&lt;td&gt;Register WhatsApp Business number with Meta via Twilio&lt;/td&gt;
&lt;td&gt;Twilio Console &amp;gt; Verify &amp;gt; WhatsApp Senders&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;Connect Twilio to Kinde&lt;/td&gt;
&lt;td&gt;Enter Twilio credentials&lt;/td&gt;
&lt;td&gt;Kinde &amp;gt; Settings &amp;gt; Phone providers&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;Enable phone auth&lt;/td&gt;
&lt;td&gt;Toggle on Phone in Passwordless section&lt;/td&gt;
&lt;td&gt;Kinde &amp;gt; Settings &amp;gt; Authentication&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;Enable WhatsApp delivery&lt;/td&gt;
&lt;td&gt;Configure WhatsApp as preferred channel&lt;/td&gt;
&lt;td&gt;Kinde &amp;gt; Settings &amp;gt; Phone providers&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;Configure MFA (optional)&lt;/td&gt;
&lt;td&gt;Enable SMS as second factor&lt;/td&gt;
&lt;td&gt;Kinde &amp;gt; Settings &amp;gt; Multi-factor auth&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;Test&lt;/td&gt;
&lt;td&gt;3-scenario flow test before going live&lt;/td&gt;
&lt;td&gt;Non-production environment&lt;/td&gt;
&lt;/tr&gt;
&lt;/tbody&gt;
&lt;/table&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.amazonaws.com%2Fuploads%2Farticles%2F8ev7wazliuvfvd9fxos3.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.amazonaws.com%2Fuploads%2Farticles%2F8ev7wazliuvfvd9fxos3.png" alt="Full delivery flow showing Your App (user enters phone number) → Kinde (routes to Twilio) → Twilio Verify (checks WhatsApp availability) → two paths: WhatsApp path (user has WhatsApp, message delivered via WhatsApp Business API, end-to-end encrypted) and SMS fallback path (user does not have WhatsApp or delivery fails, Twilio retries via SMS carrier network). Both paths return to user entering OTP in your app → Kinde verifies → user authenticatedn" width="800" height="1658"&gt;&lt;/a&gt;&lt;/p&gt;

&lt;h2&gt;
  
  
  Conclusion
&lt;/h2&gt;

&lt;p&gt;In this article, you connected Twilio Verify to Kinde, registered a WhatsApp Business Sender, enabled phone authentication, and configured WhatsApp as the preferred OTP delivery channel with automatic SMS fallback. Users in markets where WhatsApp dominates now receive their authentication codes in the app they check first, over Wi-Fi when needed, at a fraction of the SMS cost.&lt;/p&gt;

&lt;p&gt;The setup is the same for phone as primary auth and as MFA. Once your Twilio credentials are in Kinde and your WhatsApp Sender is approved, the routing is handled entirely by Twilio Verify. Your application code does not change. Kinde issues the same JWT regardless of whether the OTP arrived via WhatsApp or SMS.&lt;/p&gt;

&lt;p&gt;Kinde is free for up to 10,500 monthly active users. Phone authentication and WhatsApp OTP require the Pro plan. Create your account 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=14&amp;amp;device=&amp;amp;adposition=" rel="noopener noreferrer"&gt;kinde.com&lt;/a&gt; and meet your users where they already are.&lt;/p&gt;

</description>
      <category>kinde</category>
      <category>tutorial</category>
      <category>auth</category>
      <category>programming</category>
    </item>
    <item>
      <title>Why SMS Auth Is Quietly Failing Your Users (And How to Fix It With WhatsApp)</title>
      <dc:creator>Shola Jegede</dc:creator>
      <pubDate>Thu, 14 May 2026 12:25:03 +0000</pubDate>
      <link>https://dev.to/sholajegede/why-sms-auth-is-quietly-failing-your-users-and-how-to-fix-it-with-whatsapp-3d16</link>
      <guid>https://dev.to/sholajegede/why-sms-auth-is-quietly-failing-your-users-and-how-to-fix-it-with-whatsapp-3d16</guid>
      <description>&lt;p&gt;Here is something most developers shipping SMS OTP in 2026 do not want to sit with: the channel they are trusting to verify their users is failing roughly one in ten of them, costing the industry $71 billion per year in fraud, and is actively being legislated out of financial services in the UAE, Philippines, Singapore, Malaysia, and India.&lt;/p&gt;

&lt;p&gt;None of this shows up in your dashboard. That is the problem.&lt;/p&gt;

&lt;p&gt;SMS OTP failures are quiet. A user who never received their code does not generate an error log. They generate a support ticket, or more likely, they just leave. You see a slightly elevated drop-off at the verification step, you assume it is user error, and you move on. The system is technically working. It is just not working for everyone.&lt;/p&gt;

&lt;p&gt;This article is not going to tell you to abandon SMS entirely. For markets with low smartphone penetration or poor data connectivity, SMS is still the right fallback. What it is going to argue is that for the majority of your users in 2026, WhatsApp OTP is a demonstrably better primary channel, SMS has serious and growing structural problems that are not going to self-correct, and switching is neither difficult nor expensive. In fact, it is cheaper.&lt;/p&gt;

&lt;p&gt;Let's start with the failure modes you are not seeing.&lt;/p&gt;

&lt;h2&gt;
  
  
  The Silent Failure Rate You Are Not Measuring
&lt;/h2&gt;

&lt;p&gt;Studies consistently show that 10 to 15 percent of SMS OTPs never reach their intended recipients. The causes compound on each other: carrier filtering, network congestion, DND lists, international routing failures, grey route fraud, and plain old delivery lag that outlasts the OTP expiry window.&lt;/p&gt;

&lt;p&gt;None of these show up as errors from your SMS provider's perspective. Twilio, Vonage, and every other SMS gateway report a message as "delivered" the moment a carrier accepts it. What happens between carrier acceptance and the user's phone is invisible to you. A message accepted by a carrier at 11:59pm that arrives at 12:04am after the user has given up and closed the app counts as successfully delivered in your analytics. Your success rate looks healthy. Your signup conversion does not.&lt;/p&gt;

&lt;p&gt;The carrier filtering problem is getting worse, not better. Since February 2025, US carriers block 100 percent of unregistered A2P traffic on 10-digit long codes. In 2025, Phone-Check.app found that 23 percent of properly formatted business messages never reach inboxes due to carrier filtering and sender reputation issues alone. These are messages that follow every formatting rule and compliance requirement. They just never arrive.&lt;/p&gt;

&lt;p&gt;The pattern is consistent across every market with aggressive spam filtering. India's DLT registration system, the UK's carrier-level filtering, and the US's 10DLC compliance regime are all responses to legitimate spam problems. The side effect is that legitimate OTP delivery gets caught in the same net. Your users pay for that friction.&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.amazonaws.com%2Fuploads%2Farticles%2F8yozo4hjd0hbxkk1ud0y.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.amazonaws.com%2Fuploads%2Farticles%2F8yozo4hjd0hbxkk1ud0y.png" alt="Funnel showing SMS OTP failure points. User requests OTP (100%) → SMS provider accepts (100%) → Carrier accepts (95%) → Passes carrier filtering (77%) → Arrives before OTP expiry (72%) → User reads and enters code (65%). Each stage labeled with the failure reason. Show the 35% total drop-off as the " width="799" height="436"&gt;&lt;/a&gt;&lt;/p&gt;

&lt;h2&gt;
  
  
  The Fraud Problem That Is Eating Your OTP Budget
&lt;/h2&gt;

&lt;p&gt;The silent delivery failure is a conversion problem. The fraud problem is a financial one, and it is orders of magnitude larger.&lt;/p&gt;

&lt;p&gt;SMS pumping, also known as Artificially Inflated Traffic (AIT) or toll fraud, is a scheme where attackers abuse your OTP endpoint to generate revenue for themselves. The mechanics are straightforward: a fraudster partners with a premium-rate carrier, floods your phone verification form with bot-generated numbers on that carrier's network, and collects a share of the termination fees your SMS provider pays to deliver each message. Your users get nothing. You pay for everything.&lt;/p&gt;

&lt;p&gt;The numbers at the industry level are staggering. Messaging fraud losses were $80.5 billion in 2025, projected at $71 billion in 2026. AIT fraud specifically cost brands $1.16 billion in 2023 alone. OTPs now make up approximately 89 percent of all international A2P SMS traffic, which makes your OTP endpoint the single largest attack surface in your entire application.&lt;/p&gt;

&lt;p&gt;The most cited concrete example is Twitter. Elon Musk disclosed in 2022 that Twitter was losing approximately $60 million per year to SMS pumping through 2FA flows, driven by collusion with around 390 telecom operators. Twitter eventually cut off SMS-based two-factor authentication entirely. That is not a small company overreacting to a minor fraud problem. That is one of the largest user authentication systems in the world discovering that the default channel was being systematically exploited.&lt;/p&gt;

&lt;p&gt;The attack is not hypothetical for smaller teams either. Developers on Reddit and Hacker News regularly report waking up to five-figure surprise invoices after their OTP endpoint was pumped overnight. Most SMS providers will dispute these charges if caught quickly, but not all, and the process is slow and uncertain.&lt;/p&gt;

&lt;p&gt;What makes this particularly insidious is that it targets the exact markets where you most want phone verification to work. Africa has the highest density of high-risk SMS pumping markets globally, followed by Asia and the Caribbean. If you are building for Nigerian users, Kenyan users, Indonesian users, these are simultaneously the markets with the highest WhatsApp penetration and the highest SMS fraud risk. You are paying a premium for a channel that is both less reliable and more fraudulent in your target market than the alternative.&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.amazonaws.com%2Fuploads%2Farticles%2Flq0mm45mier37jhg8qbo.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.amazonaws.com%2Fuploads%2Farticles%2Flq0mm45mier37jhg8qbo.png" alt="SMS pumping attack flow. Your OTP form (exposed endpoint) → Bot floods with premium-rate numbers → Your SMS provider sends OTPs → Carrier delivers to fraudster-controlled numbers → Fraudster receives carrier revenue share → You receive the invoice" width="798" height="156"&gt;&lt;/a&gt;&lt;/p&gt;

&lt;h2&gt;
  
  
  The Regulatory Wall That Is Coming
&lt;/h2&gt;

&lt;p&gt;The delivery failures and the fraud are operational problems. The regulatory picture is different. It is structural and permanent.&lt;/p&gt;

&lt;p&gt;Central banks and financial regulators around the world have spent the last two years reaching the same conclusion: SMS OTP is not a secure authentication factor for financial services. The policy responses are now binding.&lt;/p&gt;

&lt;p&gt;The Philippines Bangko Sentral ng Pilipinas issued Circular No. 1213 in June 2025, requiring banks to "limit the use of authentication mechanisms that can be shared with, or intercepted by, third parties unrelated to the transaction." This directly targets SMS OTP.&lt;/p&gt;

&lt;p&gt;The UAE Central Bank issued its directive in June 2025 giving financial institutions until March 2026 to completely eliminate SMS and email OTPs. That deadline has passed. UAE banks have already made the transition.&lt;/p&gt;

&lt;p&gt;Singapore's Monetary Authority directed major banks to phase out OTPs for account logins, moving toward digital tokens. Malaysia's Bank Negara directed financial institutions to stop using SMS OTPs to combat rising financial scams. India's Reserve Bank is planning to eliminate SMS OTP for digital payments. FINRA in the United States retired SMS as an acceptable authentication option by July 2025.&lt;/p&gt;

&lt;p&gt;These are not future concerns. They are enacted policies in major markets. If you are building fintech, payments, banking, or anything adjacent to financial services and you are still SMS-first in these markets, you are already out of compliance in some of them.&lt;/p&gt;

&lt;p&gt;The underlying technical reasoning is consistent across every regulator that has weighed in. SMS does not provide a meaningful possession factor because SIM swapping lets an attacker transfer a phone number to a device they control. SS7 network attacks allow interception of SMS in transit. The channel is unencrypted. A code that can be socially engineered out of the user, intercepted in transit, or accessed by compromising the carrier does not satisfy modern strong customer authentication requirements.&lt;/p&gt;

&lt;p&gt;This argument is not going away. The regulatory trajectory for SMS OTP in financial services is one direction.&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.amazonaws.com%2Fuploads%2Farticles%2Fxx63asq6rc3pfhot1wpn.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.amazonaws.com%2Fuploads%2Farticles%2Fxx63asq6rc3pfhot1wpn.png" alt="World map or timeline showing SMS OTP regulatory actions. Philippines (June 2025 - Circular 1213), UAE (June 2025 directive, March 2026 deadline), Singapore (MAS phaseout directive), Malaysia (BNM directive), India (RBI planned elimination), USA/FINRA (July 2025 retirement)" width="800" height="379"&gt;&lt;/a&gt;&lt;/p&gt;

&lt;h2&gt;
  
  
  What WhatsApp Actually Fixes
&lt;/h2&gt;

&lt;p&gt;Before getting into the practical switch, let's be precise about what WhatsApp OTP solves and what it does not.&lt;/p&gt;

&lt;p&gt;&lt;strong&gt;It fixes delivery reliability.&lt;/strong&gt; WhatsApp messages achieve 90 to 95 percent open rates within three minutes of delivery. SMS sits at 70 to 80 percent, and that figure includes late deliveries that missed their OTP window. WhatsApp runs over data and Wi-Fi, which in most developing markets is more reliable than SMS carrier routing. A user in Lagos who has weak 2G signal but is connected to a building's Wi-Fi network will receive a WhatsApp OTP instantly and may never receive the SMS equivalent.&lt;/p&gt;

&lt;p&gt;&lt;strong&gt;It eliminates SMS pumping as an attack vector.&lt;/strong&gt; WhatsApp OTPs operate through Meta's messaging infrastructure, not through the telecom carrier network that SMS pumping exploits. The revenue-sharing fraud mechanics that make SMS pumping profitable simply do not exist in WhatsApp's system. Switching your OTP delivery to WhatsApp does not require you to implement additional fraud protection against AIT. The attack surface is gone.&lt;/p&gt;

&lt;p&gt;&lt;strong&gt;It costs substantially less.&lt;/strong&gt; A US OTP via SMS costs approximately $0.04 via Twilio. The WhatsApp authentication equivalent costs $0.006. That is 85 percent cheaper. An analysis of 219 countries found that 100 percent of countries see at least 44 percent savings when switching from SMS to WhatsApp OTP, and 58 percent of countries see 90 percent or greater savings. At 1 million monthly OTPs, the global-mix cost savings reach approximately $76,000 per month, or over $914,000 per year.&lt;/p&gt;

&lt;p&gt;&lt;strong&gt;It is end-to-end encrypted.&lt;/strong&gt; SMS travels over carrier infrastructure in plaintext and is vulnerable to SS7 attacks, SIM swapping, and interception. WhatsApp messages are end-to-end encrypted between Meta's servers and the recipient device. This does not make WhatsApp OTP invulnerable, but it eliminates the class of network-level interception attacks that have been used against SMS.&lt;/p&gt;

&lt;p&gt;&lt;strong&gt;What it does not fix:&lt;/strong&gt; WhatsApp requires the user to have the app installed and an active data or Wi-Fi connection. Roughly 5 to 10 percent of your users in most markets will not have WhatsApp. This is why the correct production setup is WhatsApp first with automatic SMS fallback, not WhatsApp as a complete replacement. You keep SMS as the safety net for the minority of users who cannot receive WhatsApp messages. You stop paying SMS rates and accepting SMS fraud risk for the 90 percent who can.&lt;/p&gt;

&lt;div class="table-wrapper-paragraph"&gt;&lt;table&gt;
&lt;thead&gt;
&lt;tr&gt;
&lt;th&gt;Feature&lt;/th&gt;
&lt;th&gt;SMS&lt;/th&gt;
&lt;th&gt;WhatsApp&lt;/th&gt;
&lt;/tr&gt;
&lt;/thead&gt;
&lt;tbody&gt;
&lt;tr&gt;
&lt;td&gt;&lt;strong&gt;Delivery rate&lt;/strong&gt;&lt;/td&gt;
&lt;td&gt;~65-77%&lt;/td&gt;
&lt;td&gt;~95%+&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;&lt;strong&gt;Open rate within 3 min&lt;/strong&gt;&lt;/td&gt;
&lt;td&gt;~20%&lt;/td&gt;
&lt;td&gt;~98%&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;&lt;strong&gt;Cost per OTP (US market)&lt;/strong&gt;&lt;/td&gt;
&lt;td&gt;~$0.0079 (Twilio)&lt;/td&gt;
&lt;td&gt;~$0.005 (Spur.com)&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;&lt;strong&gt;Cost per OTP (Nigeria/India)&lt;/strong&gt;&lt;/td&gt;
&lt;td&gt;~$0.04&lt;/td&gt;
&lt;td&gt;~$0.006 (Spur.com)&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;&lt;strong&gt;End-to-end encryption&lt;/strong&gt;&lt;/td&gt;
&lt;td&gt;❌&lt;/td&gt;
&lt;td&gt;✅&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;&lt;strong&gt;Vulnerable to SMS pumping&lt;/strong&gt;&lt;/td&gt;
&lt;td&gt;✅ Yes&lt;/td&gt;
&lt;td&gt;❌ No&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;&lt;strong&gt;Vulnerable to SS7 attacks&lt;/strong&gt;&lt;/td&gt;
&lt;td&gt;✅ Yes&lt;/td&gt;
&lt;td&gt;❌ No&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;&lt;strong&gt;Wi-Fi delivery&lt;/strong&gt;&lt;/td&gt;
&lt;td&gt;❌&lt;/td&gt;
&lt;td&gt;✅&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;&lt;strong&gt;Regulatory pressure&lt;/strong&gt;&lt;/td&gt;
&lt;td&gt;⚠️ Active bans in 6+ markets&lt;/td&gt;
&lt;td&gt;✅ Preferred channel&lt;/td&gt;
&lt;/tr&gt;
&lt;/tbody&gt;
&lt;/table&gt;&lt;/div&gt;

&lt;h2&gt;
  
  
  The Developer Counterarguments (And Why They Do Not Hold Up)
&lt;/h2&gt;

&lt;p&gt;Every time this argument comes up, three objections appear. They are worth addressing directly.&lt;/p&gt;

&lt;p&gt;&lt;strong&gt;"Not all my users have WhatsApp."&lt;/strong&gt;&lt;/p&gt;

&lt;p&gt;This is true. It is also not an argument against WhatsApp OTP. It is an argument for WhatsApp-first with SMS fallback, which is exactly what Twilio Verify's channel selection feature provides. You configure WhatsApp as the primary channel and Twilio automatically falls back to SMS when WhatsApp delivery fails. Your users who do not have WhatsApp get an SMS. Your users who do get WhatsApp. You do not choose one. You choose both, with a preference.&lt;/p&gt;

&lt;p&gt;&lt;strong&gt;"SMS is simpler to set up."&lt;/strong&gt;&lt;/p&gt;

&lt;p&gt;It was, four years ago. Today the compliance overhead for SMS (10DLC registration in the US, DLT registration in India, Sender ID registration in Nigeria) has made SMS a non-trivial setup in the markets that matter most. WhatsApp Sender setup through Twilio requires Meta Business verification and template approval, which typically takes two to five business days. That is comparable to the time required to complete US 10DLC registration, and you get a cleaner channel on the other side of it.&lt;/p&gt;

&lt;p&gt;&lt;strong&gt;"WhatsApp is controlled by Meta. That is a single point of failure."&lt;/strong&gt;&lt;/p&gt;

&lt;p&gt;This is a legitimate concern, not a dismissible one. Meta does impose messaging limits, can suspend accounts, and the WhatsApp Business API has had outages. The correct response to this concern is not to avoid WhatsApp but to maintain SMS fallback, which is the recommended setup anyway. A WhatsApp-first, SMS-fallback architecture is more resilient than SMS-only because you have two delivery paths instead of one.&lt;/p&gt;

&lt;h2&gt;
  
  
  How Kinde Makes This a One-Afternoon Integration
&lt;/h2&gt;

&lt;p&gt;If you are using Kinde for authentication, you do not need to build any of this delivery routing logic yourself. &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=15&amp;amp;device=&amp;amp;adposition=" rel="noopener noreferrer"&gt;Kinde&lt;/a&gt; handles phone OTP delivery through Twilio Verify, and when you configure a WhatsApp Sender in Twilio and connect it to Kinde, the routing is automatic. WhatsApp when available. SMS when not. No code changes.&lt;/p&gt;

&lt;p&gt;The setup is three things:&lt;/p&gt;

&lt;p&gt;&lt;strong&gt;One.&lt;/strong&gt; Connect your Twilio account to Kinde in Settings → Phone providers. You need your Twilio Account SID, Auth Token, and a Messaging Service SID or phone number.&lt;/p&gt;

&lt;p&gt;&lt;strong&gt;Two.&lt;/strong&gt; Register a WhatsApp Sender in Twilio Verify and wait for Meta approval (two to five business days). Once approved, Kinde automatically routes to WhatsApp first.&lt;/p&gt;

&lt;p&gt;&lt;strong&gt;Three.&lt;/strong&gt; Enable phone authentication in Settings → Authentication, or SMS as an MFA factor in Settings → Multi-factor auth. The WhatsApp preference follows from the Twilio configuration automatically.&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.amazonaws.com%2Fuploads%2Farticles%2Futbdiypj6d2jwqq3kegw.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.amazonaws.com%2Fuploads%2Farticles%2Futbdiypj6d2jwqq3kegw.png" alt="Kinde Settings &gt; Phone providers page showing the Twilio configuration panel with Account SID and Auth Token fields, and the WhatsApp delivery toggle enabled" width="799" height="501"&gt;&lt;/a&gt;&lt;/p&gt;

&lt;p&gt;That is the full configuration surface. Kinde gives you 10 free SMS sends per month for testing. Production phone authentication requires the Pro plan. The WhatsApp routing itself uses your Twilio Verify credits, billed at Meta's authentication rates through Twilio.&lt;/p&gt;

&lt;p&gt;The OTP message format is standardized by Kinde and cannot be customized. The delivery intelligence, the fallback logic, and the channel selection are all handled by Twilio Verify. Your application code does not change at all. A user who authenticated via WhatsApp OTP produces the same Kinde JWT as a user who authenticated via email OTP. Nothing downstream needs to know which channel was used.&lt;/p&gt;

&lt;p&gt;For full setup documentation including Twilio Sender configuration, Next.js SDK integration, and country-specific notes for Nigeria, Kenya, Indonesia, India, and Brazil, see &lt;a href="https://dev.to"&gt;the companion tutorial on dev.to&lt;/a&gt;.&lt;/p&gt;

&lt;h2&gt;
  
  
  The Actual Stakes
&lt;/h2&gt;

&lt;p&gt;Here is the case stated plainly.&lt;/p&gt;

&lt;p&gt;You are likely losing 10 to 15 percent of the users who hit your OTP verification step to silent SMS delivery failures. You are paying $0.04 or more per SMS in markets where the WhatsApp equivalent costs $0.006. You are exposed to a fraud vector that cost the industry over $80 billion in 2025 and specifically targets your OTP endpoint. You are using a channel that is being legislated out of financial services in the UAE, Philippines, Singapore, Malaysia, India, and the United States.&lt;/p&gt;

&lt;p&gt;The alternative is end-to-end encrypted, operates over Wi-Fi, costs 85 percent less in the US market and more in most developing markets, eliminates SMS pumping as an attack vector, and reaches 2.7 billion active users globally. The fallback to SMS is automatic and handles the minority of users who cannot receive WhatsApp messages.&lt;/p&gt;

&lt;p&gt;The switch requires a Twilio account, a two-to-five business day wait for Meta's WhatsApp Sender approval, and a Kinde dashboard configuration that takes about ten minutes.&lt;/p&gt;

&lt;p&gt;The users who are quietly failing your verification step are not filing support tickets. They are just not coming back. The fraud attacking your OTP endpoint is not showing up in your error logs. It is showing up in your invoice.&lt;/p&gt;

&lt;p&gt;The channel you inherited as the default is not the best available option anymore. It has not been for a while.&lt;/p&gt;

&lt;p&gt;&lt;em&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=15&amp;amp;device=&amp;amp;adposition=" rel="noopener noreferrer"&gt;Kinde&lt;/a&gt; handles authentication, billing, and feature flags in one platform. Phone OTP via WhatsApp and SMS is available on the Pro plan. Free up to 10,500 monthly active users, no credit card required.]&lt;/em&gt;&lt;/p&gt;

</description>
      <category>kinde</category>
      <category>webdev</category>
      <category>auth</category>
      <category>programming</category>
    </item>
    <item>
      <title>How to Add Free Trials to Your SaaS Without Friction: A Step-by-Step Guide With Kinde</title>
      <dc:creator>Shola Jegede</dc:creator>
      <pubDate>Thu, 14 May 2026 11:48:59 +0000</pubDate>
      <link>https://dev.to/sholajegede/how-to-add-free-trials-to-your-saas-without-friction-a-step-by-step-guide-with-kinde-23ce</link>
      <guid>https://dev.to/sholajegede/how-to-add-free-trials-to-your-saas-without-friction-a-step-by-step-guide-with-kinde-23ce</guid>
      <description>&lt;p&gt;Most SaaS products need free trials. Most free trial implementations are a mess — a &lt;code&gt;trial_expires_at&lt;/code&gt; column in the database, a cron job nobody fully trusts, middleware that sometimes runs and sometimes does not, and an upgrade prompt that appears on the wrong page at the wrong moment.&lt;/p&gt;

&lt;p&gt;In this article, you will learn:&lt;/p&gt;

&lt;ul&gt;
&lt;li&gt;Why most free trial implementations break down at the edges&lt;/li&gt;
&lt;li&gt;How Kinde's billing model works today and the honest state of native trial support&lt;/li&gt;
&lt;li&gt;How to use a $0.00 Free plan as a friction-free trial container — no credit card required&lt;/li&gt;
&lt;li&gt;How to stamp a &lt;code&gt;trial_start&lt;/code&gt; date onto every new user using Kinde Workflows&lt;/li&gt;
&lt;li&gt;How to surface that date as a custom claim in the access token so your app never queries a database to check trial status&lt;/li&gt;
&lt;li&gt;How to build a &lt;code&gt;useTrialStatus&lt;/code&gt; hook that drives every trial-aware UI decision in one place&lt;/li&gt;
&lt;li&gt;How to build the upgrade prompt that appears at day 7, day 13, and on expiry&lt;/li&gt;
&lt;li&gt;How to transition the user to a paid plan the moment the trial ends — smoothly, with no data loss&lt;/li&gt;
&lt;li&gt;How to test the full trial lifecycle without waiting 14 days&lt;/li&gt;
&lt;/ul&gt;

&lt;p&gt;Let's dive in!&lt;/p&gt;

&lt;h2&gt;
  
  
  The Free Trial Problem Nobody Talks About
&lt;/h2&gt;

&lt;p&gt;Free trials are not hard to implement. They are hard to implement correctly. The obvious approach — store a trial end date in your database and check it on every request — works until it does not. The cron job that downgrades expired trial users runs at 3am but the user is in a different timezone. The middleware check runs on API routes but not on the WebSocket connection. The upgrade prompt fires but the user already paid ten minutes ago and the webhook has not processed yet.&lt;/p&gt;

&lt;p&gt;The underlying issue is that trial state lives in multiple places — your database, Stripe, your app's middleware — and those places are never perfectly in sync.&lt;/p&gt;

&lt;p&gt;The cleaner approach keeps trial state in one place: the Kinde access token. Every time the user makes a request, the token carries their &lt;code&gt;trial_start&lt;/code&gt; date and their current plan. Your app reads those claims, computes whether the trial is active, expired, or converted, and acts accordingly. No database query. No sync lag. No cron jobs.&lt;/p&gt;

&lt;p&gt;Here is how to build that.&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.amazonaws.com%2Fuploads%2Farticles%2F1o4ep9j546tntgr6a5bg.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.amazonaws.com%2Fuploads%2Farticles%2F1o4ep9j546tntgr6a5bg.png" alt="LEFT " width="800" height="731"&gt;&lt;/a&gt;&lt;/p&gt;

&lt;h2&gt;
  
  
  How Kinde Billing Works Today — And One Important Caveat
&lt;/h2&gt;

&lt;p&gt;Before writing a single line of code, one thing needs to be stated clearly: as of mid-2026, Kinde Billing does not have native free trial support built in. The Kinde docs list "support for plan models with free trial periods" as a known limitation that is actively being worked on.&lt;/p&gt;

&lt;p&gt;This article does not work around that limitation — it builds a production-grade trial system using the Kinde primitives that exist today: a $0.00 Free plan, custom user properties, Kinde Workflows, and feature flags. The result is a trial system that is arguably more flexible than a built-in trial toggle would be, because you control the logic entirely.&lt;/p&gt;

&lt;p&gt;When Kinde ships native trial support, the &lt;code&gt;trial_start&lt;/code&gt; workflow and the expiry logic in your app can be replaced with a single plan configuration. The feature-gating code using &lt;code&gt;getBooleanFlag&lt;/code&gt; stays exactly the same. The migration will be clean.&lt;/p&gt;

&lt;p&gt;Note: everything in this article uses Kinde's non-production environment for setup and Stripe test mode for payments. Do not touch production until you have walked the full flow in test mode.&lt;/p&gt;

&lt;h2&gt;
  
  
  The Architecture You Are Building
&lt;/h2&gt;

&lt;p&gt;The trial system has four moving parts:&lt;/p&gt;

&lt;p&gt;&lt;strong&gt;1. A Free plan&lt;/strong&gt; configured in Kinde Billing with a $0.00 charge, credit card collection disabled, and the trial feature flags attached to it. This is what users land on when they sign up.&lt;/p&gt;

&lt;p&gt;&lt;strong&gt;2. A Workflow&lt;/strong&gt; that fires on user post-authentication and stamps a &lt;code&gt;trial_start&lt;/code&gt; Unix timestamp onto the user's Kinde property record the first time they authenticate.&lt;/p&gt;

&lt;p&gt;&lt;strong&gt;3. Token customization&lt;/strong&gt; that surfaces the &lt;code&gt;trial_start&lt;/code&gt; property and the user's current plan flags in the access token, so your app can read trial state without a database lookup.&lt;/p&gt;

&lt;p&gt;&lt;strong&gt;4. A &lt;code&gt;useTrialStatus&lt;/code&gt; hook&lt;/strong&gt; in your Next.js app that reads those token claims, computes the trial state, and drives every trial-aware UI decision — the countdown banner, the upgrade prompt, the locked feature overlay, and the expired paywall.&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.amazonaws.com%2Fuploads%2Farticles%2Fmoc7cqbap7savvpmaddd.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.amazonaws.com%2Fuploads%2Farticles%2Fmoc7cqbap7savvpmaddd.png" alt="Four-part architecture showing Free plan (Kinde Billing) → Workflow stamps trial_start → Token carries trial_start + plan flags → useTrialStatus hook drives UI. Linear left-to-right flow with each component labeled and color coded" width="800" height="93"&gt;&lt;/a&gt;&lt;/p&gt;

&lt;h2&gt;
  
  
  Step #1: Create the Free Plan in Kinde Billing
&lt;/h2&gt;

&lt;p&gt;Navigate to &lt;strong&gt;Billing → Plans → Add plan&lt;/strong&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.amazonaws.com%2Fuploads%2Farticles%2Fry89ofjkybx5e5k4te0d.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.amazonaws.com%2Fuploads%2Farticles%2Fry89ofjkybx5e5k4te0d.png" alt="Kinde Billing &gt; Plans page showing the " width="800" height="502"&gt;&lt;/a&gt;&lt;/p&gt;

&lt;p&gt;Configure the plan with these settings:&lt;/p&gt;

&lt;ul&gt;
&lt;li&gt;
&lt;strong&gt;Name&lt;/strong&gt;: Free Trial (this is what users see on the pricing table)&lt;/li&gt;
&lt;li&gt;
&lt;strong&gt;Description&lt;/strong&gt;: 14 days of full Pro access, no credit card required&lt;/li&gt;
&lt;li&gt;
&lt;strong&gt;Key&lt;/strong&gt;: &lt;code&gt;free_trial&lt;/code&gt; (this is how you reference it in code — cannot be changed after publishing)&lt;/li&gt;
&lt;li&gt;
&lt;strong&gt;Plan type&lt;/strong&gt;: Users (for B2C) or Organizations (for B2B)&lt;/li&gt;
&lt;/ul&gt;

&lt;p&gt;After saving the basic details, add a charge. Every plan in Kinde — including free plans — needs at least one charge so it syncs to Stripe correctly.&lt;/p&gt;

&lt;p&gt;Select &lt;strong&gt;Add charge&lt;/strong&gt;. Configure it as:&lt;/p&gt;

&lt;ul&gt;
&lt;li&gt;
&lt;strong&gt;Name&lt;/strong&gt;: Base subscription fee&lt;/li&gt;
&lt;li&gt;
&lt;strong&gt;Amount&lt;/strong&gt;: $0.00&lt;/li&gt;
&lt;li&gt;
&lt;strong&gt;Interval&lt;/strong&gt;: Monthly&lt;/li&gt;
&lt;/ul&gt;

&lt;p&gt;Select &lt;strong&gt;Save&lt;/strong&gt;.&lt;/p&gt;

&lt;p&gt;Now configure the credit card collection setting. Because this is a free trial plan with a $0.00 charge, Kinde allows you to disable credit card collection. Scroll to the &lt;strong&gt;Payment collection&lt;/strong&gt; section and disable the credit card requirement.&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.amazonaws.com%2Fuploads%2Farticles%2Fz1901zkpmqqvura1jc6f.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.amazonaws.com%2Fuploads%2Farticles%2Fz1901zkpmqqvura1jc6f.png" alt="Kinde plan settings showing the Payment collection section with the credit card requirement toggle disabled" width="800" height="502"&gt;&lt;/a&gt;&lt;/p&gt;

&lt;p&gt;Note: credit card collection can only be disabled for plans with no paid charges. The moment you add a charge above $0.00, Kinde requires card collection. Your Free Trial plan stays at $0.00 for this reason — users sign up without friction and you collect card details only when they upgrade.&lt;/p&gt;

&lt;p&gt;Now attach the feature flags that control what trial users can access. Navigate to the &lt;strong&gt;Features&lt;/strong&gt; section of the plan and attach the flags that represent your Pro tier features — things like &lt;code&gt;advanced_analytics&lt;/code&gt;, &lt;code&gt;api_access&lt;/code&gt;, &lt;code&gt;export_data&lt;/code&gt;. These flags will activate automatically for any user on the Free Trial plan.&lt;/p&gt;

&lt;p&gt;![Kinde plan features section showing three feature flags attached to the Free Trial plan: advanced_analytics (boolean, true), api_access (boolean, true), export_data (boolean, true). These represent the Pro features users get during the trial]](&lt;a href="https://dev-to-uploads.s3.amazonaws.com/uploads/articles/uetjbd1rngmv91tvvu7d.png" rel="noopener noreferrer"&gt;https://dev-to-uploads.s3.amazonaws.com/uploads/articles/uetjbd1rngmv91tvvu7d.png&lt;/a&gt;)&lt;/p&gt;

&lt;p&gt;Select &lt;strong&gt;Publish&lt;/strong&gt; to make the plan live. Published plans sync to Stripe automatically.&lt;/p&gt;

&lt;p&gt;Terrific! The trial container plan exists. Now stamp the start date.&lt;/p&gt;

&lt;h2&gt;
  
  
  Step #2: Stamp the Trial Start Date With a Kinde Workflow
&lt;/h2&gt;

&lt;p&gt;Kinde Workflows let you run custom code in response to Kinde events. The trigger you need is &lt;code&gt;user:post_authentication&lt;/code&gt; — it fires after a user authenticates and before the token is issued, giving you a window to set properties on the user record.&lt;/p&gt;

&lt;p&gt;First, create the custom property that will hold the trial start date. Navigate to &lt;strong&gt;Settings → Data management → Properties → Add property&lt;/strong&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.amazonaws.com%2Fuploads%2Farticles%2Fe2z6o6edq205vyr3y760.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.amazonaws.com%2Fuploads%2Farticles%2Fe2z6o6edq205vyr3y760.png" alt="Kinde Settings &gt; Data management &gt; Properties page showing the " start="" width="800" height="502"&gt;&lt;/a&gt;&lt;/p&gt;

&lt;p&gt;Configure the property:&lt;/p&gt;

&lt;ul&gt;
&lt;li&gt;
&lt;strong&gt;Name&lt;/strong&gt;: Trial start date&lt;/li&gt;
&lt;li&gt;
&lt;strong&gt;Key&lt;/strong&gt;: &lt;code&gt;trial_start&lt;/code&gt; (cannot be changed later)&lt;/li&gt;
&lt;li&gt;
&lt;strong&gt;Type&lt;/strong&gt;: Single line text (you will store a Unix timestamp as a string)&lt;/li&gt;
&lt;li&gt;
&lt;strong&gt;Private&lt;/strong&gt;: off — this must be public so you can include it in the access token&lt;/li&gt;
&lt;/ul&gt;

&lt;p&gt;Select &lt;strong&gt;Save&lt;/strong&gt;.&lt;/p&gt;

&lt;p&gt;Now create the workflow. Kinde Workflows live in your code repository and are synced to Kinde via GitHub. The recommended way to get started is to use the official Kinde workflow base template, which has the correct folder structure already set up.&lt;/p&gt;

&lt;p&gt;Navigate to &lt;a href="https://github.com/kinde-starter-kits/workflow-base-template" rel="noopener noreferrer"&gt;github.com/kinde-starter-kits/workflow-base-template&lt;/a&gt;, click the green Use this template button, and select Create a new repository. This creates a repo with the following structure:&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight plaintext"&gt;&lt;code&gt;kindeSrc/
└── environment/
    └── workflows/
        └── postUserAuthentication/
            └── Workflow.ts
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;Replace the contents of &lt;code&gt;postUserAuthentication/Workflow.ts&lt;/code&gt; with the trial start workflow:&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;onPostAuthenticationEvent&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt;
  &lt;span class="nx"&gt;WorkflowSettings&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt;
  &lt;span class="nx"&gt;WorkflowTrigger&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt;
  &lt;span class="nx"&gt;createKindeAPI&lt;/span&gt;&lt;span class="p"&gt;,&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/infrastructure&lt;/span&gt;&lt;span class="dl"&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;workflowSettings&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="nx"&gt;WorkflowSettings&lt;/span&gt; &lt;span class="o"&gt;=&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="dl"&gt;"&lt;/span&gt;&lt;span class="s2"&gt;trialStartWorkflow&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="dl"&gt;"&lt;/span&gt;&lt;span class="s2"&gt;Stamp trial start date&lt;/span&gt;&lt;span class="dl"&gt;"&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt;
  &lt;span class="na"&gt;failurePolicy&lt;/span&gt;&lt;span class="p"&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="dl"&gt;"&lt;/span&gt;&lt;span class="s2"&gt;continue&lt;/span&gt;&lt;span class="dl"&gt;"&lt;/span&gt; &lt;span class="p"&gt;},&lt;/span&gt;
  &lt;span class="na"&gt;bindings&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;kinde.fetch&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="na"&gt;trigger&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="nx"&gt;WorkflowTrigger&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nx"&gt;PostAuthentication&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="k"&gt;default&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;Workflow&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;onPostAuthenticationEvent&lt;/span&gt;&lt;span class="p"&gt;)&lt;/span&gt; &lt;span class="p"&gt;{&lt;/span&gt;
  &lt;span class="c1"&gt;// Only stamp the trial start on new users — never overwrite existing value&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;event&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;isExistingUser&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="p"&gt;}&lt;/span&gt;

  &lt;span class="kd"&gt;const&lt;/span&gt; &lt;span class="nx"&gt;userId&lt;/span&gt; &lt;span class="o"&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;context&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="kd"&gt;const&lt;/span&gt; &lt;span class="nx"&gt;trialStart&lt;/span&gt; &lt;span class="o"&gt;=&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;floor&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="mi"&gt;1000&lt;/span&gt;&lt;span class="p"&gt;).&lt;/span&gt;&lt;span class="nf"&gt;toString&lt;/span&gt;&lt;span class="p"&gt;();&lt;/span&gt;

  &lt;span class="c1"&gt;// Use the Kinde Management API to set the trial_start property on the user&lt;/span&gt;
  &lt;span class="kd"&gt;const&lt;/span&gt; &lt;span class="nx"&gt;kindeAPI&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;createKindeAPI&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="k"&gt;await&lt;/span&gt; &lt;span class="nx"&gt;kindeAPI&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nf"&gt;patch&lt;/span&gt;&lt;span class="p"&gt;({&lt;/span&gt;
    &lt;span class="na"&gt;endpoint&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="s2"&gt;`user`&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;id&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;requestBody&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="p"&gt;{&lt;/span&gt;
      &lt;span class="na"&gt;properties&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="p"&gt;{&lt;/span&gt;
        &lt;span class="na"&gt;trial_start&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="nx"&gt;trialStart&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;Commit and push the file to your repository. Then in your Kinde dashboard, navigate to Settings → Git repo, connect your repository, select your branch, and hit Sync code. Once the sync succeeds, navigate to Workflows — the "Stamp trial start date" workflow will appear with status Live.&lt;/p&gt;

&lt;blockquote&gt;
&lt;p&gt;Note: the &lt;code&gt;isExistingUser&lt;/code&gt; check is critical. Without it, the workflow would overwrite &lt;code&gt;trial_start&lt;/code&gt; on every login, resetting the trial clock each time the user authenticates. The check ensures the timestamp is only written once — on first sign-in.&lt;/p&gt;
&lt;/blockquote&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.amazonaws.com%2Fuploads%2Farticles%2Fthiqwh4fe3spteo715dp.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.amazonaws.com%2Fuploads%2Farticles%2Fthiqwh4fe3spteo715dp.png" alt="Kinde dashboard showing the Workflows section with the " start="" width="800" height="502"&gt;&lt;/a&gt;&lt;/p&gt;

&lt;p&gt;Note: the &lt;code&gt;isExistingUser&lt;/code&gt; check is critical. Without it, the workflow would overwrite the &lt;code&gt;trial_start&lt;/code&gt; on every login, resetting the trial clock each time the user authenticates. The check ensures the timestamp is only written once — on first sign-in.&lt;/p&gt;

&lt;h2&gt;
  
  
  Step #3: Surface Trial State in the Access Token
&lt;/h2&gt;

&lt;p&gt;The &lt;code&gt;trial_start&lt;/code&gt; property is now stored on the user record in Kinde. To make it available in the access token without a database query, add it to the token customization settings for your application.&lt;/p&gt;

&lt;p&gt;Navigate to &lt;strong&gt;Settings → Applications → [your app] → Tokens → Customize&lt;/strong&gt; on the Access token card.&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.amazonaws.com%2Fuploads%2Farticles%2Fstz0qtkc1np8jdjdasgn.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.amazonaws.com%2Fuploads%2Farticles%2Fstz0qtkc1np8jdjdasgn.png" alt="Kinde application token customization dialog showing the Properties section with " width="800" height="502"&gt;&lt;/a&gt;&lt;/p&gt;

&lt;p&gt;In the Customize access token dialog, scroll to the &lt;strong&gt;Properties&lt;/strong&gt; section and toggle on &lt;code&gt;trial_start&lt;/code&gt;. Select &lt;strong&gt;Save&lt;/strong&gt;.&lt;/p&gt;

&lt;p&gt;From this point, every access token issued to your users will contain an &lt;code&gt;application_properties&lt;/code&gt; claim with the &lt;code&gt;trial_start&lt;/code&gt; value:&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;"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_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;"email"&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt;&lt;span class="w"&gt; &lt;/span&gt;&lt;span class="s2"&gt;"user@example.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;"feature_flags"&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;"advanced_analytics"&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;"t"&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt;&lt;span class="w"&gt; &lt;/span&gt;&lt;span class="s2"&gt;"b"&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="kc"&gt;true&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;"api_access"&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;"t"&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt;&lt;span class="w"&gt; &lt;/span&gt;&lt;span class="s2"&gt;"b"&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="kc"&gt;true&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;"export_data"&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;"t"&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt;&lt;span class="w"&gt; &lt;/span&gt;&lt;span class="s2"&gt;"b"&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="kc"&gt;true&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;"application_properties"&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;"trial_start"&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;"1717200000"&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;"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;"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;1717286400&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 &lt;code&gt;feature_flags&lt;/code&gt; claim carries the Pro features active on the Free Trial plan. The &lt;code&gt;application_properties.trial_start.v&lt;/code&gt; claim carries the Unix timestamp of when the trial started. Your app now has everything it needs to compute trial status without touching the database.&lt;/p&gt;

&lt;p&gt;Amazing!&lt;/p&gt;

&lt;h2&gt;
  
  
  Step #4: Build the &lt;code&gt;useTrialStatus&lt;/code&gt; Hook
&lt;/h2&gt;

&lt;p&gt;This hook is the single source of truth for trial state in your React app. Every trial-aware component — the countdown banner, the upgrade prompt, the feature gate, the expired paywall — reads from this hook.&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;// hooks/useTrialStatus.ts&lt;/span&gt;
&lt;span class="dl"&gt;"&lt;/span&gt;&lt;span class="s2"&gt;use client&lt;/span&gt;&lt;span class="dl"&gt;"&lt;/span&gt;&lt;span class="p"&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;useKindeBrowserClient&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&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;TRIAL_LENGTH_DAYS&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="mi"&gt;14&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;TRIAL_LENGTH_SECONDS&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="nx"&gt;TRIAL_LENGTH_DAYS&lt;/span&gt; &lt;span class="o"&gt;*&lt;/span&gt; &lt;span class="mi"&gt;24&lt;/span&gt; &lt;span class="o"&gt;*&lt;/span&gt; &lt;span class="mi"&gt;60&lt;/span&gt; &lt;span class="o"&gt;*&lt;/span&gt; &lt;span class="mi"&gt;60&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;type&lt;/span&gt; &lt;span class="nx"&gt;TrialStatus&lt;/span&gt; &lt;span class="o"&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;active&lt;/span&gt;&lt;span class="dl"&gt;"&lt;/span&gt;      &lt;span class="c1"&gt;// Trial is running, days remain&lt;/span&gt;
  &lt;span class="o"&gt;|&lt;/span&gt; &lt;span class="dl"&gt;"&lt;/span&gt;&lt;span class="s2"&gt;expiring&lt;/span&gt;&lt;span class="dl"&gt;"&lt;/span&gt;    &lt;span class="c1"&gt;// Trial has 3 or fewer days left&lt;/span&gt;
  &lt;span class="o"&gt;|&lt;/span&gt; &lt;span class="dl"&gt;"&lt;/span&gt;&lt;span class="s2"&gt;expired&lt;/span&gt;&lt;span class="dl"&gt;"&lt;/span&gt;     &lt;span class="c1"&gt;// Trial ended, user has not upgraded&lt;/span&gt;
  &lt;span class="o"&gt;|&lt;/span&gt; &lt;span class="dl"&gt;"&lt;/span&gt;&lt;span class="s2"&gt;converted&lt;/span&gt;&lt;span class="dl"&gt;"&lt;/span&gt;&lt;span class="p"&gt;;&lt;/span&gt;  &lt;span class="c1"&gt;// User is on a paid plan&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;TrialState&lt;/span&gt; &lt;span class="p"&gt;{&lt;/span&gt;
  &lt;span class="nl"&gt;status&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="nx"&gt;TrialStatus&lt;/span&gt;&lt;span class="p"&gt;;&lt;/span&gt;
  &lt;span class="nl"&gt;daysRemaining&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="kr"&gt;number&lt;/span&gt;&lt;span class="p"&gt;;&lt;/span&gt;
  &lt;span class="nl"&gt;trialStartDate&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="nb"&gt;Date&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="nl"&gt;trialEndDate&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="nb"&gt;Date&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="nl"&gt;isPro&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="k"&gt;export&lt;/span&gt; &lt;span class="kd"&gt;function&lt;/span&gt; &lt;span class="nf"&gt;useTrialStatus&lt;/span&gt;&lt;span class="p"&gt;():&lt;/span&gt; &lt;span class="nx"&gt;TrialState&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;getClaim&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="nx"&gt;getBooleanFlag&lt;/span&gt; &lt;span class="p"&gt;}&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="nf"&gt;useKindeBrowserClient&lt;/span&gt;&lt;span class="p"&gt;();&lt;/span&gt;

  &lt;span class="c1"&gt;// Read trial_start from the application_properties claim&lt;/span&gt;
  &lt;span class="c1"&gt;// The value is a Unix timestamp stored as a string&lt;/span&gt;
  &lt;span class="kd"&gt;const&lt;/span&gt; &lt;span class="nx"&gt;trialStartClaim&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="nf"&gt;getClaim&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_properties&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;access_token&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;trialStartRaw&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="nx"&gt;trialStartClaim&lt;/span&gt;&lt;span class="p"&gt;?.&lt;/span&gt;&lt;span class="nx"&gt;value&lt;/span&gt; &lt;span class="k"&gt;as&lt;/span&gt; &lt;span class="kr"&gt;any&lt;/span&gt;&lt;span class="p"&gt;)?.&lt;/span&gt;&lt;span class="nx"&gt;trial_start&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="kd"&gt;const&lt;/span&gt; &lt;span class="nx"&gt;trialStartSeconds&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="nx"&gt;trialStartRaw&lt;/span&gt; &lt;span class="p"&gt;?&lt;/span&gt; &lt;span class="nf"&gt;parseInt&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="nx"&gt;trialStartRaw&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="mi"&gt;10&lt;/span&gt;&lt;span class="p"&gt;)&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;// Read feature flags to determine if the user has Pro access&lt;/span&gt;
  &lt;span class="c1"&gt;// These flags are active on both the Free Trial plan and the paid Pro plan&lt;/span&gt;
  &lt;span class="kd"&gt;const&lt;/span&gt; &lt;span class="nx"&gt;hasAdvancedAnalytics&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="nf"&gt;getBooleanFlag&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="dl"&gt;"&lt;/span&gt;&lt;span class="s2"&gt;advanced_analytics&lt;/span&gt;&lt;span class="dl"&gt;"&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="kd"&gt;const&lt;/span&gt; &lt;span class="nx"&gt;hasApiAccess&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="nf"&gt;getBooleanFlag&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_access&lt;/span&gt;&lt;span class="dl"&gt;"&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="c1"&gt;// A user is "Pro" if they have the paid plan flags active&lt;/span&gt;
  &lt;span class="c1"&gt;// When the Free Trial expires, these flags go false unless they upgrade&lt;/span&gt;
  &lt;span class="c1"&gt;// Note: during the trial, these are also true — so use trial state to distinguish&lt;/span&gt;
  &lt;span class="kd"&gt;const&lt;/span&gt; &lt;span class="nx"&gt;hasPlanAccess&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="nx"&gt;hasAdvancedAnalytics&lt;/span&gt; &lt;span class="o"&gt;&amp;amp;&amp;amp;&lt;/span&gt; &lt;span class="nx"&gt;hasApiAccess&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;trialStartSeconds&lt;/span&gt;&lt;span class="p"&gt;)&lt;/span&gt; &lt;span class="p"&gt;{&lt;/span&gt;
    &lt;span class="c1"&gt;// No trial_start means this is a legacy user or the workflow has not run yet&lt;/span&gt;
    &lt;span class="c1"&gt;// Treat as expired to avoid unintended access&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;expired&lt;/span&gt;&lt;span class="dl"&gt;"&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt;
      &lt;span class="na"&gt;daysRemaining&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;trialStartDate&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="na"&gt;trialEndDate&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="na"&gt;isPro&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="nx"&gt;hasPlanAccess&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;now&lt;/span&gt; &lt;span class="o"&gt;=&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;floor&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="mi"&gt;1000&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;trialEndSeconds&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="nx"&gt;trialStartSeconds&lt;/span&gt; &lt;span class="o"&gt;+&lt;/span&gt; &lt;span class="nx"&gt;TRIAL_LENGTH_SECONDS&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;secondsRemaining&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="nx"&gt;trialEndSeconds&lt;/span&gt; &lt;span class="o"&gt;-&lt;/span&gt; &lt;span class="nx"&gt;now&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;daysRemaining&lt;/span&gt; &lt;span class="o"&gt;=&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;max&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="nb"&gt;Math&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nf"&gt;ceil&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="nx"&gt;secondsRemaining&lt;/span&gt; &lt;span class="o"&gt;/&lt;/span&gt; &lt;span class="mi"&gt;86400&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;trialStartDate&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;Date&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="nx"&gt;trialStartSeconds&lt;/span&gt; &lt;span class="o"&gt;*&lt;/span&gt; &lt;span class="mi"&gt;1000&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;trialEndDate&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;Date&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="nx"&gt;trialEndSeconds&lt;/span&gt; &lt;span class="o"&gt;*&lt;/span&gt; &lt;span class="mi"&gt;1000&lt;/span&gt;&lt;span class="p"&gt;);&lt;/span&gt;

  &lt;span class="c1"&gt;// If the user has Pro feature flags active AND the trial has expired,&lt;/span&gt;
  &lt;span class="c1"&gt;// they have upgraded — they are a paying customer&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;hasPlanAccess&lt;/span&gt; &lt;span class="o"&gt;&amp;amp;&amp;amp;&lt;/span&gt; &lt;span class="nx"&gt;secondsRemaining&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="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;converted&lt;/span&gt;&lt;span class="dl"&gt;"&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt;
      &lt;span class="na"&gt;daysRemaining&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;trialStartDate&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt;
      &lt;span class="nx"&gt;trialEndDate&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt;
      &lt;span class="na"&gt;isPro&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="k"&gt;if &lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="nx"&gt;secondsRemaining&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="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;expired&lt;/span&gt;&lt;span class="dl"&gt;"&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt;
      &lt;span class="na"&gt;daysRemaining&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;trialStartDate&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt;
      &lt;span class="nx"&gt;trialEndDate&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt;
      &lt;span class="na"&gt;isPro&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="k"&gt;if &lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="nx"&gt;daysRemaining&lt;/span&gt; &lt;span class="o"&gt;&amp;lt;=&lt;/span&gt; &lt;span class="mi"&gt;3&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;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;expiring&lt;/span&gt;&lt;span class="dl"&gt;"&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt;
      &lt;span class="nx"&gt;daysRemaining&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt;
      &lt;span class="nx"&gt;trialStartDate&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt;
      &lt;span class="nx"&gt;trialEndDate&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt;
      &lt;span class="na"&gt;isPro&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="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;active&lt;/span&gt;&lt;span class="dl"&gt;"&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt;
    &lt;span class="nx"&gt;daysRemaining&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt;
    &lt;span class="nx"&gt;trialStartDate&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt;
    &lt;span class="nx"&gt;trialEndDate&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt;
    &lt;span class="na"&gt;isPro&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;Note: the hook reads &lt;code&gt;application_properties&lt;/code&gt; from the access token. The Kinde browser client's &lt;code&gt;getClaim&lt;/code&gt; method defaults to reading from the access token, which is what you want here. The &lt;code&gt;getBooleanFlag&lt;/code&gt; method also reads from the token — no network request is made.&lt;/p&gt;

&lt;h2&gt;
  
  
  Step #5: Build the Trial UI Components
&lt;/h2&gt;

&lt;p&gt;With the &lt;code&gt;useTrialStatus&lt;/code&gt; hook in place, build the UI components that surface trial state to users.&lt;/p&gt;

&lt;h3&gt;
  
  
  The Trial Banner
&lt;/h3&gt;

&lt;p&gt;This sits at the top of the dashboard and counts down the remaining days. It changes color and urgency as the trial approaches expiry.&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight tsx"&gt;&lt;code&gt;&lt;span class="c1"&gt;// components/TrialBanner.tsx&lt;/span&gt;
&lt;span class="dl"&gt;"&lt;/span&gt;&lt;span class="s2"&gt;use client&lt;/span&gt;&lt;span class="dl"&gt;"&lt;/span&gt;&lt;span class="p"&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;useTrialStatus&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;@/hooks/useTrialStatus&lt;/span&gt;&lt;span class="dl"&gt;"&lt;/span&gt;&lt;span class="p"&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;RegisterLink&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/components&lt;/span&gt;&lt;span class="dl"&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;function&lt;/span&gt; &lt;span class="nf"&gt;TrialBanner&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;status&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="nx"&gt;daysRemaining&lt;/span&gt; &lt;span class="p"&gt;}&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="nf"&gt;useTrialStatus&lt;/span&gt;&lt;span class="p"&gt;();&lt;/span&gt;

  &lt;span class="c1"&gt;// Do not show the banner for converted users or when there is no trial data&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;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;converted&lt;/span&gt;&lt;span class="dl"&gt;"&lt;/span&gt; &lt;span class="o"&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;expired&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;isUrgent&lt;/span&gt; &lt;span class="o"&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;expiring&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="p"&gt;&amp;lt;&lt;/span&gt;&lt;span class="nt"&gt;div&lt;/span&gt;
      &lt;span class="na"&gt;className&lt;/span&gt;&lt;span class="p"&gt;=&lt;/span&gt;&lt;span class="si"&gt;{&lt;/span&gt;&lt;span class="s2"&gt;`w-full px-4 py-2 text-sm font-medium text-center flex items-center justify-center gap-4 &lt;/span&gt;&lt;span class="p"&gt;${&lt;/span&gt;
        &lt;span class="nx"&gt;isUrgent&lt;/span&gt;
          &lt;span class="p"&gt;?&lt;/span&gt; &lt;span class="dl"&gt;"&lt;/span&gt;&lt;span class="s2"&gt;bg-red-50 text-red-700 border-b border-red-200&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;bg-blue-50 text-blue-700 border-b border-blue-200&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="si"&gt;}&lt;/span&gt;
    &lt;span class="p"&gt;&amp;gt;&lt;/span&gt;
      &lt;span class="p"&gt;&amp;lt;&lt;/span&gt;&lt;span class="nt"&gt;span&lt;/span&gt;&lt;span class="p"&gt;&amp;gt;&lt;/span&gt;
        &lt;span class="si"&gt;{&lt;/span&gt;&lt;span class="nx"&gt;isUrgent&lt;/span&gt;
          &lt;span class="p"&gt;?&lt;/span&gt; &lt;span class="s2"&gt;`Your trial expires in &lt;/span&gt;&lt;span class="p"&gt;${&lt;/span&gt;&lt;span class="nx"&gt;daysRemaining&lt;/span&gt;&lt;span class="p"&gt;}&lt;/span&gt;&lt;span class="s2"&gt; day&lt;/span&gt;&lt;span class="p"&gt;${&lt;/span&gt;&lt;span class="nx"&gt;daysRemaining&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="dl"&gt;"&lt;/span&gt;&lt;span class="s2"&gt;s&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="s2"&gt;. Upgrade to keep your access.`&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;daysRemaining&lt;/span&gt;&lt;span class="p"&gt;}&lt;/span&gt;&lt;span class="s2"&gt; days left in your free trial.`&lt;/span&gt;&lt;span class="si"&gt;}&lt;/span&gt;
      &lt;span class="p"&gt;&amp;lt;/&lt;/span&gt;&lt;span class="nt"&gt;span&lt;/span&gt;&lt;span class="p"&gt;&amp;gt;&lt;/span&gt;
      &lt;span class="p"&gt;&amp;lt;&lt;/span&gt;&lt;span class="nc"&gt;RegisterLink&lt;/span&gt;
        &lt;span class="na"&gt;planInterest&lt;/span&gt;&lt;span class="p"&gt;=&lt;/span&gt;&lt;span class="s"&gt;"pro_monthly"&lt;/span&gt;
        &lt;span class="na"&gt;className&lt;/span&gt;&lt;span class="p"&gt;=&lt;/span&gt;&lt;span class="si"&gt;{&lt;/span&gt;&lt;span class="s2"&gt;`px-3 py-1 rounded text-xs font-semibold &lt;/span&gt;&lt;span class="p"&gt;${&lt;/span&gt;
          &lt;span class="nx"&gt;isUrgent&lt;/span&gt;
            &lt;span class="p"&gt;?&lt;/span&gt; &lt;span class="dl"&gt;"&lt;/span&gt;&lt;span class="s2"&gt;bg-red-600 text-white hover:bg-red-700&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;bg-blue-600 text-white hover:bg-blue-700&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="si"&gt;}&lt;/span&gt;
      &lt;span class="p"&gt;&amp;gt;&lt;/span&gt;
        Upgrade now
      &lt;span class="p"&gt;&amp;lt;/&lt;/span&gt;&lt;span class="nc"&gt;RegisterLink&lt;/span&gt;&lt;span class="p"&gt;&amp;gt;&lt;/span&gt;
    &lt;span class="p"&gt;&amp;lt;/&lt;/span&gt;&lt;span class="nt"&gt;div&lt;/span&gt;&lt;span class="p"&gt;&amp;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;h3&gt;
  
  
  The Expired Paywall
&lt;/h3&gt;

&lt;p&gt;When the trial ends, replace the dashboard content with an upgrade prompt. Users can still access their data — they just cannot take action until they upgrade.&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight tsx"&gt;&lt;code&gt;&lt;span class="c1"&gt;// components/TrialExpiredPaywall.tsx&lt;/span&gt;
&lt;span class="dl"&gt;"&lt;/span&gt;&lt;span class="s2"&gt;use client&lt;/span&gt;&lt;span class="dl"&gt;"&lt;/span&gt;&lt;span class="p"&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;useTrialStatus&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;@/hooks/useTrialStatus&lt;/span&gt;&lt;span class="dl"&gt;"&lt;/span&gt;&lt;span class="p"&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;RegisterLink&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/components&lt;/span&gt;&lt;span class="dl"&gt;"&lt;/span&gt;&lt;span class="p"&gt;;&lt;/span&gt;

&lt;span class="kr"&gt;interface&lt;/span&gt; &lt;span class="nx"&gt;TrialExpiredPaywallProps&lt;/span&gt; &lt;span class="p"&gt;{&lt;/span&gt;
  &lt;span class="nl"&gt;children&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="nx"&gt;React&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nx"&gt;ReactNode&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;function&lt;/span&gt; &lt;span class="nf"&gt;TrialExpiredPaywall&lt;/span&gt;&lt;span class="p"&gt;({&lt;/span&gt; &lt;span class="nx"&gt;children&lt;/span&gt; &lt;span class="p"&gt;}:&lt;/span&gt; &lt;span class="nx"&gt;TrialExpiredPaywallProps&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;status&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="nx"&gt;trialEndDate&lt;/span&gt; &lt;span class="p"&gt;}&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="nf"&gt;useTrialStatus&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;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;expired&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;// Trial is active or user has converted — show the content normally&lt;/span&gt;
    &lt;span class="k"&gt;return&lt;/span&gt; &lt;span class="p"&gt;&amp;lt;&amp;gt;&lt;/span&gt;&lt;span class="si"&gt;{&lt;/span&gt;&lt;span class="nx"&gt;children&lt;/span&gt;&lt;span class="si"&gt;}&lt;/span&gt;&lt;span class="p"&gt;&amp;lt;/&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;endDateFormatted&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="nx"&gt;trialEndDate&lt;/span&gt;
    &lt;span class="p"&gt;?&lt;/span&gt; &lt;span class="nx"&gt;trialEndDate&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nf"&gt;toLocaleDateString&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="dl"&gt;"&lt;/span&gt;&lt;span class="s2"&gt;en-US&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;month&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="dl"&gt;"&lt;/span&gt;&lt;span class="s2"&gt;long&lt;/span&gt;&lt;span class="dl"&gt;"&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt;
        &lt;span class="na"&gt;day&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="dl"&gt;"&lt;/span&gt;&lt;span class="s2"&gt;numeric&lt;/span&gt;&lt;span class="dl"&gt;"&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt;
        &lt;span class="na"&gt;year&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="dl"&gt;"&lt;/span&gt;&lt;span class="s2"&gt;numeric&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="dl"&gt;"&lt;/span&gt;&lt;span class="s2"&gt;recently&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="p"&gt;&amp;lt;&lt;/span&gt;&lt;span class="nt"&gt;div&lt;/span&gt; &lt;span class="na"&gt;className&lt;/span&gt;&lt;span class="p"&gt;=&lt;/span&gt;&lt;span class="s"&gt;"relative"&lt;/span&gt;&lt;span class="p"&gt;&amp;gt;&lt;/span&gt;
      &lt;span class="si"&gt;{&lt;/span&gt;&lt;span class="cm"&gt;/* Blur the underlying content so users can see what they are missing */&lt;/span&gt;&lt;span class="si"&gt;}&lt;/span&gt;
      &lt;span class="p"&gt;&amp;lt;&lt;/span&gt;&lt;span class="nt"&gt;div&lt;/span&gt; &lt;span class="na"&gt;className&lt;/span&gt;&lt;span class="p"&gt;=&lt;/span&gt;&lt;span class="s"&gt;"pointer-events-none select-none blur-sm opacity-40"&lt;/span&gt;&lt;span class="p"&gt;&amp;gt;&lt;/span&gt;
        &lt;span class="si"&gt;{&lt;/span&gt;&lt;span class="nx"&gt;children&lt;/span&gt;&lt;span class="si"&gt;}&lt;/span&gt;
      &lt;span class="p"&gt;&amp;lt;/&lt;/span&gt;&lt;span class="nt"&gt;div&lt;/span&gt;&lt;span class="p"&gt;&amp;gt;&lt;/span&gt;

      &lt;span class="si"&gt;{&lt;/span&gt;&lt;span class="cm"&gt;/* Paywall overlay */&lt;/span&gt;&lt;span class="si"&gt;}&lt;/span&gt;
      &lt;span class="p"&gt;&amp;lt;&lt;/span&gt;&lt;span class="nt"&gt;div&lt;/span&gt; &lt;span class="na"&gt;className&lt;/span&gt;&lt;span class="p"&gt;=&lt;/span&gt;&lt;span class="s"&gt;"absolute inset-0 flex items-center justify-center"&lt;/span&gt;&lt;span class="p"&gt;&amp;gt;&lt;/span&gt;
        &lt;span class="p"&gt;&amp;lt;&lt;/span&gt;&lt;span class="nt"&gt;div&lt;/span&gt; &lt;span class="na"&gt;className&lt;/span&gt;&lt;span class="p"&gt;=&lt;/span&gt;&lt;span class="s"&gt;"bg-white rounded-xl shadow-xl border border-gray-200 p-8 max-w-md w-full mx-4 text-center space-y-4"&lt;/span&gt;&lt;span class="p"&gt;&amp;gt;&lt;/span&gt;
          &lt;span class="p"&gt;&amp;lt;&lt;/span&gt;&lt;span class="nt"&gt;div&lt;/span&gt; &lt;span class="na"&gt;className&lt;/span&gt;&lt;span class="p"&gt;=&lt;/span&gt;&lt;span class="s"&gt;"text-2xl font-bold text-gray-900"&lt;/span&gt;&lt;span class="p"&gt;&amp;gt;&lt;/span&gt;
            Your trial ended on &lt;span class="si"&gt;{&lt;/span&gt;&lt;span class="nx"&gt;endDateFormatted&lt;/span&gt;&lt;span class="si"&gt;}&lt;/span&gt;
          &lt;span class="p"&gt;&amp;lt;/&lt;/span&gt;&lt;span class="nt"&gt;div&lt;/span&gt;&lt;span class="p"&gt;&amp;gt;&lt;/span&gt;
          &lt;span class="p"&gt;&amp;lt;&lt;/span&gt;&lt;span class="nt"&gt;p&lt;/span&gt; &lt;span class="na"&gt;className&lt;/span&gt;&lt;span class="p"&gt;=&lt;/span&gt;&lt;span class="s"&gt;"text-gray-500 text-sm"&lt;/span&gt;&lt;span class="p"&gt;&amp;gt;&lt;/span&gt;
            Your data is safe. Upgrade to Pro to get back to work.
          &lt;span class="p"&gt;&amp;lt;/&lt;/span&gt;&lt;span class="nt"&gt;p&lt;/span&gt;&lt;span class="p"&gt;&amp;gt;&lt;/span&gt;

          &lt;span class="p"&gt;&amp;lt;&lt;/span&gt;&lt;span class="nc"&gt;RegisterLink&lt;/span&gt;
            &lt;span class="na"&gt;planInterest&lt;/span&gt;&lt;span class="p"&gt;=&lt;/span&gt;&lt;span class="s"&gt;"pro_monthly"&lt;/span&gt;
            &lt;span class="na"&gt;className&lt;/span&gt;&lt;span class="p"&gt;=&lt;/span&gt;&lt;span class="s"&gt;"block w-full py-3 px-6 bg-black text-white rounded-lg font-semibold text-sm hover:bg-gray-800 transition-colors"&lt;/span&gt;
          &lt;span class="p"&gt;&amp;gt;&lt;/span&gt;
            Upgrade to Pro
          &lt;span class="p"&gt;&amp;lt;/&lt;/span&gt;&lt;span class="nc"&gt;RegisterLink&lt;/span&gt;&lt;span class="p"&gt;&amp;gt;&lt;/span&gt;

          &lt;span class="p"&gt;&amp;lt;&lt;/span&gt;&lt;span class="nt"&gt;p&lt;/span&gt; &lt;span class="na"&gt;className&lt;/span&gt;&lt;span class="p"&gt;=&lt;/span&gt;&lt;span class="s"&gt;"text-xs text-gray-400"&lt;/span&gt;&lt;span class="p"&gt;&amp;gt;&lt;/span&gt;
            No setup required. Your account picks up exactly where you left off.
          &lt;span class="p"&gt;&amp;lt;/&lt;/span&gt;&lt;span class="nt"&gt;p&lt;/span&gt;&lt;span class="p"&gt;&amp;gt;&lt;/span&gt;
        &lt;span class="p"&gt;&amp;lt;/&lt;/span&gt;&lt;span class="nt"&gt;div&lt;/span&gt;&lt;span class="p"&gt;&amp;gt;&lt;/span&gt;
      &lt;span class="p"&gt;&amp;lt;/&lt;/span&gt;&lt;span class="nt"&gt;div&lt;/span&gt;&lt;span class="p"&gt;&amp;gt;&lt;/span&gt;
    &lt;span class="p"&gt;&amp;lt;/&lt;/span&gt;&lt;span class="nt"&gt;div&lt;/span&gt;&lt;span class="p"&gt;&amp;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;h3&gt;
  
  
  The Feature Gate
&lt;/h3&gt;

&lt;p&gt;For features that are available during the trial but locked on a free-forever tier, use this gate to show an upgrade prompt in context rather than a full-page paywall.&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight tsx"&gt;&lt;code&gt;&lt;span class="c1"&gt;// components/FeatureGate.tsx&lt;/span&gt;
&lt;span class="dl"&gt;"&lt;/span&gt;&lt;span class="s2"&gt;use client&lt;/span&gt;&lt;span class="dl"&gt;"&lt;/span&gt;&lt;span class="p"&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;useTrialStatus&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;@/hooks/useTrialStatus&lt;/span&gt;&lt;span class="dl"&gt;"&lt;/span&gt;&lt;span class="p"&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;RegisterLink&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/components&lt;/span&gt;&lt;span class="dl"&gt;"&lt;/span&gt;&lt;span class="p"&gt;;&lt;/span&gt;

&lt;span class="kr"&gt;interface&lt;/span&gt; &lt;span class="nx"&gt;FeatureGateProps&lt;/span&gt; &lt;span class="p"&gt;{&lt;/span&gt;
  &lt;span class="nl"&gt;feature&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;children&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="nx"&gt;React&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nx"&gt;ReactNode&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;function&lt;/span&gt; &lt;span class="nf"&gt;FeatureGate&lt;/span&gt;&lt;span class="p"&gt;({&lt;/span&gt; &lt;span class="nx"&gt;feature&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="nx"&gt;children&lt;/span&gt; &lt;span class="p"&gt;}:&lt;/span&gt; &lt;span class="nx"&gt;FeatureGateProps&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;isPro&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="o"&gt;=&lt;/span&gt; &lt;span class="nf"&gt;useTrialStatus&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;isPro&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;&amp;lt;&amp;gt;&lt;/span&gt;&lt;span class="si"&gt;{&lt;/span&gt;&lt;span class="nx"&gt;children&lt;/span&gt;&lt;span class="si"&gt;}&lt;/span&gt;&lt;span class="p"&gt;&amp;lt;/&amp;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="p"&gt;&amp;lt;&lt;/span&gt;&lt;span class="nt"&gt;div&lt;/span&gt; &lt;span class="na"&gt;className&lt;/span&gt;&lt;span class="p"&gt;=&lt;/span&gt;&lt;span class="s"&gt;"relative rounded-lg border border-gray-200 p-6"&lt;/span&gt;&lt;span class="p"&gt;&amp;gt;&lt;/span&gt;
      &lt;span class="p"&gt;&amp;lt;&lt;/span&gt;&lt;span class="nt"&gt;div&lt;/span&gt; &lt;span class="na"&gt;className&lt;/span&gt;&lt;span class="p"&gt;=&lt;/span&gt;&lt;span class="s"&gt;"pointer-events-none select-none blur-sm opacity-40"&lt;/span&gt;&lt;span class="p"&gt;&amp;gt;&lt;/span&gt;
        &lt;span class="si"&gt;{&lt;/span&gt;&lt;span class="nx"&gt;children&lt;/span&gt;&lt;span class="si"&gt;}&lt;/span&gt;
      &lt;span class="p"&gt;&amp;lt;/&lt;/span&gt;&lt;span class="nt"&gt;div&lt;/span&gt;&lt;span class="p"&gt;&amp;gt;&lt;/span&gt;
      &lt;span class="p"&gt;&amp;lt;&lt;/span&gt;&lt;span class="nt"&gt;div&lt;/span&gt; &lt;span class="na"&gt;className&lt;/span&gt;&lt;span class="p"&gt;=&lt;/span&gt;&lt;span class="s"&gt;"absolute inset-0 flex items-center justify-center rounded-lg bg-white/80"&lt;/span&gt;&lt;span class="p"&gt;&amp;gt;&lt;/span&gt;
        &lt;span class="p"&gt;&amp;lt;&lt;/span&gt;&lt;span class="nt"&gt;div&lt;/span&gt; &lt;span class="na"&gt;className&lt;/span&gt;&lt;span class="p"&gt;=&lt;/span&gt;&lt;span class="s"&gt;"text-center space-y-2 px-4"&lt;/span&gt;&lt;span class="p"&gt;&amp;gt;&lt;/span&gt;
          &lt;span class="p"&gt;&amp;lt;&lt;/span&gt;&lt;span class="nt"&gt;p&lt;/span&gt; &lt;span class="na"&gt;className&lt;/span&gt;&lt;span class="p"&gt;=&lt;/span&gt;&lt;span class="s"&gt;"text-sm font-semibold text-gray-900"&lt;/span&gt;&lt;span class="p"&gt;&amp;gt;&lt;/span&gt;
            &lt;span class="si"&gt;{&lt;/span&gt;&lt;span class="nx"&gt;feature&lt;/span&gt;&lt;span class="si"&gt;}&lt;/span&gt; is a Pro feature
          &lt;span class="p"&gt;&amp;lt;/&lt;/span&gt;&lt;span class="nt"&gt;p&lt;/span&gt;&lt;span class="p"&gt;&amp;gt;&lt;/span&gt;
          &lt;span class="si"&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;expired&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;&amp;lt;&lt;/span&gt;&lt;span class="nc"&gt;RegisterLink&lt;/span&gt;
              &lt;span class="na"&gt;planInterest&lt;/span&gt;&lt;span class="p"&gt;=&lt;/span&gt;&lt;span class="s"&gt;"pro_monthly"&lt;/span&gt;
              &lt;span class="na"&gt;className&lt;/span&gt;&lt;span class="p"&gt;=&lt;/span&gt;&lt;span class="s"&gt;"inline-block px-4 py-2 bg-black text-white rounded-md text-xs font-medium"&lt;/span&gt;
            &lt;span class="p"&gt;&amp;gt;&lt;/span&gt;
              Upgrade to unlock
            &lt;span class="p"&gt;&amp;lt;/&lt;/span&gt;&lt;span class="nc"&gt;RegisterLink&lt;/span&gt;&lt;span class="p"&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="p"&gt;(&lt;/span&gt;
            &lt;span class="p"&gt;&amp;lt;&lt;/span&gt;&lt;span class="nt"&gt;p&lt;/span&gt; &lt;span class="na"&gt;className&lt;/span&gt;&lt;span class="p"&gt;=&lt;/span&gt;&lt;span class="s"&gt;"text-xs text-gray-500"&lt;/span&gt;&lt;span class="p"&gt;&amp;gt;&lt;/span&gt;
              Available during your trial — upgrade to keep access after it ends.
            &lt;span class="p"&gt;&amp;lt;/&lt;/span&gt;&lt;span class="nt"&gt;p&lt;/span&gt;&lt;span class="p"&gt;&amp;gt;&lt;/span&gt;
          &lt;span class="p"&gt;)&lt;/span&gt;&lt;span class="si"&gt;}&lt;/span&gt;
        &lt;span class="p"&gt;&amp;lt;/&lt;/span&gt;&lt;span class="nt"&gt;div&lt;/span&gt;&lt;span class="p"&gt;&amp;gt;&lt;/span&gt;
      &lt;span class="p"&gt;&amp;lt;/&lt;/span&gt;&lt;span class="nt"&gt;div&lt;/span&gt;&lt;span class="p"&gt;&amp;gt;&lt;/span&gt;
    &lt;span class="p"&gt;&amp;lt;/&lt;/span&gt;&lt;span class="nt"&gt;div&lt;/span&gt;&lt;span class="p"&gt;&amp;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;h3&gt;
  
  
  Putting the Components Together
&lt;/h3&gt;

&lt;p&gt;Use all three in your dashboard layout:&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight tsx"&gt;&lt;code&gt;&lt;span class="c1"&gt;// app/dashboard/layout.tsx&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;TrialBanner&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;@/components/TrialBanner&lt;/span&gt;&lt;span class="dl"&gt;"&lt;/span&gt;&lt;span class="p"&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;TrialExpiredPaywall&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;@/components/TrialExpiredPaywall&lt;/span&gt;&lt;span class="dl"&gt;"&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;default&lt;/span&gt; &lt;span class="kd"&gt;function&lt;/span&gt; &lt;span class="nf"&gt;DashboardLayout&lt;/span&gt;&lt;span class="p"&gt;({&lt;/span&gt;
  &lt;span class="nx"&gt;children&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;children&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="nx"&gt;React&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nx"&gt;ReactNode&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="p"&gt;&amp;lt;&lt;/span&gt;&lt;span class="nt"&gt;div&lt;/span&gt; &lt;span class="na"&gt;className&lt;/span&gt;&lt;span class="p"&gt;=&lt;/span&gt;&lt;span class="s"&gt;"min-h-screen flex flex-col"&lt;/span&gt;&lt;span class="p"&gt;&amp;gt;&lt;/span&gt;
      &lt;span class="si"&gt;{&lt;/span&gt;&lt;span class="cm"&gt;/* Trial countdown banner — hidden when converted or expired */&lt;/span&gt;&lt;span class="si"&gt;}&lt;/span&gt;
      &lt;span class="p"&gt;&amp;lt;&lt;/span&gt;&lt;span class="nc"&gt;TrialBanner&lt;/span&gt; &lt;span class="p"&gt;/&amp;gt;&lt;/span&gt;

      &lt;span class="p"&gt;&amp;lt;&lt;/span&gt;&lt;span class="nt"&gt;main&lt;/span&gt; &lt;span class="na"&gt;className&lt;/span&gt;&lt;span class="p"&gt;=&lt;/span&gt;&lt;span class="s"&gt;"flex-1"&lt;/span&gt;&lt;span class="p"&gt;&amp;gt;&lt;/span&gt;
        &lt;span class="si"&gt;{&lt;/span&gt;&lt;span class="cm"&gt;/* Paywall wraps all dashboard content — activates only on expiry */&lt;/span&gt;&lt;span class="si"&gt;}&lt;/span&gt;
        &lt;span class="p"&gt;&amp;lt;&lt;/span&gt;&lt;span class="nc"&gt;TrialExpiredPaywall&lt;/span&gt;&lt;span class="p"&gt;&amp;gt;&lt;/span&gt;&lt;span class="si"&gt;{&lt;/span&gt;&lt;span class="nx"&gt;children&lt;/span&gt;&lt;span class="si"&gt;}&lt;/span&gt;&lt;span class="p"&gt;&amp;lt;/&lt;/span&gt;&lt;span class="nc"&gt;TrialExpiredPaywall&lt;/span&gt;&lt;span class="p"&gt;&amp;gt;&lt;/span&gt;
      &lt;span class="p"&gt;&amp;lt;/&lt;/span&gt;&lt;span class="nt"&gt;main&lt;/span&gt;&lt;span class="p"&gt;&amp;gt;&lt;/span&gt;
    &lt;span class="p"&gt;&amp;lt;/&lt;/span&gt;&lt;span class="nt"&gt;div&lt;/span&gt;&lt;span class="p"&gt;&amp;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 individual features, wrap the component with &lt;code&gt;FeatureGate&lt;/code&gt;:&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight tsx"&gt;&lt;code&gt;&lt;span class="c1"&gt;// app/dashboard/analytics/page.tsx&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;FeatureGate&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;@/components/FeatureGate&lt;/span&gt;&lt;span class="dl"&gt;"&lt;/span&gt;&lt;span class="p"&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;AdvancedAnalyticsDashboard&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;@/components/AdvancedAnalyticsDashboard&lt;/span&gt;&lt;span class="dl"&gt;"&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;default&lt;/span&gt; &lt;span class="kd"&gt;function&lt;/span&gt; &lt;span class="nf"&gt;AnalyticsPage&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="p"&gt;&amp;lt;&lt;/span&gt;&lt;span class="nt"&gt;div&lt;/span&gt; &lt;span class="na"&gt;className&lt;/span&gt;&lt;span class="p"&gt;=&lt;/span&gt;&lt;span class="s"&gt;"p-6"&lt;/span&gt;&lt;span class="p"&gt;&amp;gt;&lt;/span&gt;
      &lt;span class="p"&gt;&amp;lt;&lt;/span&gt;&lt;span class="nt"&gt;h1&lt;/span&gt; &lt;span class="na"&gt;className&lt;/span&gt;&lt;span class="p"&gt;=&lt;/span&gt;&lt;span class="s"&gt;"text-xl font-semibold mb-4"&lt;/span&gt;&lt;span class="p"&gt;&amp;gt;&lt;/span&gt;Analytics&lt;span class="p"&gt;&amp;lt;/&lt;/span&gt;&lt;span class="nt"&gt;h1&lt;/span&gt;&lt;span class="p"&gt;&amp;gt;&lt;/span&gt;
      &lt;span class="p"&gt;&amp;lt;&lt;/span&gt;&lt;span class="nc"&gt;FeatureGate&lt;/span&gt; &lt;span class="na"&gt;feature&lt;/span&gt;&lt;span class="p"&gt;=&lt;/span&gt;&lt;span class="s"&gt;"Advanced Analytics"&lt;/span&gt;&lt;span class="p"&gt;&amp;gt;&lt;/span&gt;
        &lt;span class="p"&gt;&amp;lt;&lt;/span&gt;&lt;span class="nc"&gt;AdvancedAnalyticsDashboard&lt;/span&gt; &lt;span class="p"&gt;/&amp;gt;&lt;/span&gt;
      &lt;span class="p"&gt;&amp;lt;/&lt;/span&gt;&lt;span class="nc"&gt;FeatureGate&lt;/span&gt;&lt;span class="p"&gt;&amp;gt;&lt;/span&gt;
    &lt;span class="p"&gt;&amp;lt;/&lt;/span&gt;&lt;span class="nt"&gt;div&lt;/span&gt;&lt;span class="p"&gt;&amp;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;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.amazonaws.com%2Fuploads%2Farticles%2Fzquwlc11mebswt6nwjbd.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.amazonaws.com%2Fuploads%2Farticles%2Fzquwlc11mebswt6nwjbd.png" alt="Dashboard showing the TrialBanner at the top with " width="800" height="502"&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.amazonaws.com%2Fuploads%2Farticles%2Fk5o82magvcpi9q7f6cfz.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.amazonaws.com%2Fuploads%2Farticles%2Fk5o82magvcpi9q7f6cfz.png" alt="The same dashboard with the trial expired — content blurred behind the TrialExpiredPaywall overlay showing " width="800" height="502"&gt;&lt;/a&gt;&lt;/p&gt;

&lt;h2&gt;
  
  
  Step #6: Handle the Upgrade — Moving From Trial to Paid
&lt;/h2&gt;

&lt;p&gt;When a trial user clicks "Upgrade to Pro," they go through Kinde's hosted payment flow via the &lt;code&gt;RegisterLink&lt;/code&gt; component with &lt;code&gt;planInterest="pro_monthly"&lt;/code&gt;. After payment, Kinde switches the user's plan from &lt;code&gt;free_trial&lt;/code&gt; to &lt;code&gt;pro_monthly&lt;/code&gt;.&lt;/p&gt;

&lt;p&gt;Two things happen in your app automatically:&lt;/p&gt;

&lt;p&gt;First, the feature flags attached to the Pro plan remain active. The &lt;code&gt;advanced_analytics&lt;/code&gt;, &lt;code&gt;api_access&lt;/code&gt;, and &lt;code&gt;export_data&lt;/code&gt; flags do not disappear — they continue to resolve as &lt;code&gt;true&lt;/code&gt; because they are also attached to the Pro plan. The user notices no change in what they can access.&lt;/p&gt;

&lt;p&gt;Second, the &lt;code&gt;useTrialStatus&lt;/code&gt; hook detects that the trial has ended but the plan flags are still active, and returns &lt;code&gt;status: "converted"&lt;/code&gt;. The paywall never appears. The banner disappears.&lt;/p&gt;

&lt;p&gt;On the server side, Kinde fires a webhook when the subscription is created. Use this to record the conversion in your own database if needed:&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;// app/api/webhooks/kinde/route.ts&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;getKindeWebhookDecodedEvent&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/webhooks&lt;/span&gt;&lt;span class="dl"&gt;"&lt;/span&gt;&lt;span class="p"&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;NextRequest&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="nx"&gt;NextResponse&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;next/server&lt;/span&gt;&lt;span class="dl"&gt;"&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;POST&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;NextRequest&lt;/span&gt;&lt;span class="p"&gt;)&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;event&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;event&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;getKindeWebhookDecodedEvent&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="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="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="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="s2"&gt;Unauthorized&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;status&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="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;event&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;subscription.created&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="p"&gt;{&lt;/span&gt; &lt;span class="nx"&gt;user_id&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="nx"&gt;plan_key&lt;/span&gt; &lt;span class="p"&gt;}&lt;/span&gt; &lt;span class="o"&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;data&lt;/span&gt; &lt;span class="k"&gt;as&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="kr"&gt;string&lt;/span&gt;&lt;span class="p"&gt;;&lt;/span&gt;
      &lt;span class="nl"&gt;plan_key&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="c1"&gt;// Record the conversion — trial user is now a paid customer&lt;/span&gt;
    &lt;span class="k"&gt;await&lt;/span&gt; &lt;span class="nx"&gt;db&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nx"&gt;users&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nf"&gt;update&lt;/span&gt;&lt;span class="p"&gt;({&lt;/span&gt;
      &lt;span class="na"&gt;where&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="p"&gt;{&lt;/span&gt; &lt;span class="na"&gt;kindeId&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="nx"&gt;user_id&lt;/span&gt; &lt;span class="p"&gt;},&lt;/span&gt;
      &lt;span class="na"&gt;data&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="p"&gt;{&lt;/span&gt;
        &lt;span class="na"&gt;plan&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="nx"&gt;plan_key&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt;
        &lt;span class="na"&gt;convertedAt&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;Date&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;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="s2"&gt;`Trial converted: user &lt;/span&gt;&lt;span class="p"&gt;${&lt;/span&gt;&lt;span class="nx"&gt;user_id&lt;/span&gt;&lt;span class="p"&gt;}&lt;/span&gt;&lt;span class="s2"&gt; upgraded to &lt;/span&gt;&lt;span class="p"&gt;${&lt;/span&gt;&lt;span class="nx"&gt;plan_key&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;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="na"&gt;received&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;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;Note: the webhook is for your own record-keeping. The access control in your app should always read from the token — not from your database's &lt;code&gt;plan&lt;/code&gt; column — because the token is always authoritative. The database record is useful for analytics, billing history, and support tooling.&lt;/p&gt;

&lt;h2&gt;
  
  
  Step #7: Test the Full Trial Lifecycle Without Waiting 14 Days
&lt;/h2&gt;

&lt;p&gt;Testing a 14-day trial by waiting 14 days is not practical. Here are the three approaches for simulating trial state in development.&lt;/p&gt;

&lt;h3&gt;
  
  
  Approach 1: Manipulate the Trial Start Date
&lt;/h3&gt;

&lt;p&gt;The quickest way to simulate an expiring or expired trial is to set the &lt;code&gt;trial_start&lt;/code&gt; property to a past timestamp via 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=13&amp;amp;device=&amp;amp;adposition=" rel="noopener noreferrer"&gt;Kinde Management API&lt;/a&gt;. Create a test utility:&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;// scripts/set-trial-date.ts&lt;/span&gt;
&lt;span class="c1"&gt;// Usage: npx ts-node scripts/set-trial-date.ts &amp;lt;userId&amp;gt; &amp;lt;daysAgo&amp;gt;&lt;/span&gt;
&lt;span class="c1"&gt;// Example: npx ts-node scripts/set-trial-date.ts kp_abc123 13&lt;/span&gt;
&lt;span class="c1"&gt;//          Sets trial_start to 13 days ago — 1 day until expiry&lt;/span&gt;

&lt;span class="kd"&gt;const&lt;/span&gt; &lt;span class="nx"&gt;userId&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;argv&lt;/span&gt;&lt;span class="p"&gt;[&lt;/span&gt;&lt;span class="mi"&gt;2&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;daysAgo&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="nf"&gt;parseInt&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;argv&lt;/span&gt;&lt;span class="p"&gt;[&lt;/span&gt;&lt;span class="mi"&gt;3&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;0&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;trialStart&lt;/span&gt; &lt;span class="o"&gt;=&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;floor&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="mi"&gt;1000&lt;/span&gt;&lt;span class="p"&gt;)&lt;/span&gt; &lt;span class="o"&gt;-&lt;/span&gt; &lt;span class="nx"&gt;daysAgo&lt;/span&gt; &lt;span class="o"&gt;*&lt;/span&gt; &lt;span class="mi"&gt;86400&lt;/span&gt;&lt;span class="p"&gt;;&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;setTrialDate&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;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;`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="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="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;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;`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/user?id=&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="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;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="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;properties&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="p"&gt;{&lt;/span&gt;
          &lt;span class="na"&gt;trial_start&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="nx"&gt;trialStart&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nf"&gt;toString&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;if &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;ok&lt;/span&gt;&lt;span class="p"&gt;)&lt;/span&gt; &lt;span class="p"&gt;{&lt;/span&gt;
    &lt;span class="nx"&gt;console&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nf"&gt;log&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;
      &lt;span class="s2"&gt;`Set trial_start to &lt;/span&gt;&lt;span class="p"&gt;${&lt;/span&gt;&lt;span class="nx"&gt;daysAgo&lt;/span&gt;&lt;span class="p"&gt;}&lt;/span&gt;&lt;span class="s2"&gt; days ago for user &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="s2"&gt;`&lt;/span&gt;
    &lt;span class="p"&gt;);&lt;/span&gt;
    &lt;span class="nx"&gt;console&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nf"&gt;log&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="s2"&gt;`Trial expires in &lt;/span&gt;&lt;span class="p"&gt;${&lt;/span&gt;&lt;span class="mi"&gt;14&lt;/span&gt; &lt;span class="o"&gt;-&lt;/span&gt; &lt;span class="nx"&gt;daysAgo&lt;/span&gt;&lt;span class="p"&gt;}&lt;/span&gt;&lt;span class="s2"&gt; days`&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="nf"&gt;setTrialDate&lt;/span&gt;&lt;span class="p"&gt;();&lt;/span&gt;
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;Run it to simulate each trial state:&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight shell"&gt;&lt;code&gt;&lt;span class="c"&gt;# Active trial — 10 days remaining&lt;/span&gt;
npx ts-node scripts/set-trial-date.ts kp_abc123 4

&lt;span class="c"&gt;# Expiring trial — 2 days remaining&lt;/span&gt;
npx ts-node scripts/set-trial-date.ts kp_abc123 12

&lt;span class="c"&gt;# Expired trial&lt;/span&gt;
npx ts-node scripts/set-trial-date.ts kp_abc123 15
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;After running the script, the user needs to re-authenticate to get a fresh token with the updated &lt;code&gt;trial_start&lt;/code&gt; value.&lt;/p&gt;

&lt;h3&gt;
  
  
  Approach 2: Override Trial Length in the Hook
&lt;/h3&gt;

&lt;p&gt;For rapid UI development, add a &lt;code&gt;TRIAL_OVERRIDE_DAYS&lt;/code&gt; environment variable that overrides the trial length:&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;// In useTrialStatus.ts, replace the constant with:&lt;/span&gt;
&lt;span class="kd"&gt;const&lt;/span&gt; &lt;span class="nx"&gt;TRIAL_LENGTH_DAYS&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;NEXT_PUBLIC_TRIAL_OVERRIDE_DAYS&lt;/span&gt;
  &lt;span class="p"&gt;?&lt;/span&gt; &lt;span class="nf"&gt;parseInt&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;NEXT_PUBLIC_TRIAL_OVERRIDE_DAYS&lt;/span&gt;&lt;span class="p"&gt;)&lt;/span&gt;
  &lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="mi"&gt;14&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;NEXT_PUBLIC_TRIAL_OVERRIDE_DAYS=1&lt;/code&gt; in &lt;code&gt;.env.local&lt;/code&gt; during development. A trial that started today will expire tomorrow, letting you test the full lifecycle in 24 hours.&lt;/p&gt;

&lt;h3&gt;
  
  
  Approach 3: Use the Trial Status Storybook
&lt;/h3&gt;

&lt;p&gt;For component-level testing, pass mock trial state directly to the components by extracting the hook logic into a context provider that can be overridden in tests:&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;// This pattern lets you render &amp;lt;TrialBanner /&amp;gt; with any trial state&lt;/span&gt;
&lt;span class="c1"&gt;// in Storybook or Jest without needing a real Kinde token&lt;/span&gt;
&lt;span class="kd"&gt;const&lt;/span&gt; &lt;span class="nx"&gt;mockTrialState&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="nx"&gt;TrialState&lt;/span&gt; &lt;span class="o"&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="dl"&gt;"&lt;/span&gt;&lt;span class="s2"&gt;expiring&lt;/span&gt;&lt;span class="dl"&gt;"&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt;
  &lt;span class="na"&gt;daysRemaining&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="mi"&gt;2&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt;
  &lt;span class="na"&gt;trialStartDate&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;Date&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="mi"&gt;12&lt;/span&gt; &lt;span class="o"&gt;*&lt;/span&gt; &lt;span class="mi"&gt;86400&lt;/span&gt; &lt;span class="o"&gt;*&lt;/span&gt; &lt;span class="mi"&gt;1000&lt;/span&gt;&lt;span class="p"&gt;),&lt;/span&gt;
  &lt;span class="na"&gt;trialEndDate&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;Date&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="mi"&gt;2&lt;/span&gt; &lt;span class="o"&gt;*&lt;/span&gt; &lt;span class="mi"&gt;86400&lt;/span&gt; &lt;span class="o"&gt;*&lt;/span&gt; &lt;span class="mi"&gt;1000&lt;/span&gt;&lt;span class="p"&gt;),&lt;/span&gt;
  &lt;span class="na"&gt;isPro&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;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;Wonderful! The full trial lifecycle — from first sign-up through expiry and upgrade — is now testable without waiting.&lt;/p&gt;

&lt;h2&gt;
  
  
  Putting It All Together
&lt;/h2&gt;

&lt;p&gt;Here is the complete trial system mapped across all layers:&lt;/p&gt;

&lt;div class="table-wrapper-paragraph"&gt;&lt;table&gt;
&lt;thead&gt;
&lt;tr&gt;
&lt;th&gt;Layer&lt;/th&gt;
&lt;th&gt;What it does&lt;/th&gt;
&lt;th&gt;Where it lives&lt;/th&gt;
&lt;/tr&gt;
&lt;/thead&gt;
&lt;tbody&gt;
&lt;tr&gt;
&lt;td&gt;Free Trial plan (Kinde)&lt;/td&gt;
&lt;td&gt;$0.00 plan, no credit card, Pro feature flags attached&lt;/td&gt;
&lt;td&gt;Kinde Billing dashboard&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;Workflow&lt;/td&gt;
&lt;td&gt;Stamps &lt;code&gt;trial_start&lt;/code&gt; Unix timestamp on first sign-in&lt;/td&gt;
&lt;td&gt;
&lt;code&gt;trialStartWorkflow.ts&lt;/code&gt; pushed to Kinde&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;Token customization&lt;/td&gt;
&lt;td&gt;Surfaces &lt;code&gt;trial_start&lt;/code&gt; and feature flags in the access token&lt;/td&gt;
&lt;td&gt;Kinde application settings&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;
&lt;code&gt;useTrialStatus&lt;/code&gt; hook&lt;/td&gt;
&lt;td&gt;Reads token claims, computes trial state&lt;/td&gt;
&lt;td&gt;&lt;code&gt;hooks/useTrialStatus.ts&lt;/code&gt;&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;&lt;code&gt;TrialBanner&lt;/code&gt;&lt;/td&gt;
&lt;td&gt;Shows countdown, changes urgency at day 11&lt;/td&gt;
&lt;td&gt;&lt;code&gt;components/TrialBanner.tsx&lt;/code&gt;&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;&lt;code&gt;TrialExpiredPaywall&lt;/code&gt;&lt;/td&gt;
&lt;td&gt;Blurs content, shows upgrade prompt on expiry&lt;/td&gt;
&lt;td&gt;&lt;code&gt;components/TrialExpiredPaywall.tsx&lt;/code&gt;&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;&lt;code&gt;FeatureGate&lt;/code&gt;&lt;/td&gt;
&lt;td&gt;Locks individual features after expiry&lt;/td&gt;
&lt;td&gt;&lt;code&gt;components/FeatureGate.tsx&lt;/code&gt;&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;Webhook handler&lt;/td&gt;
&lt;td&gt;Records conversion in your database&lt;/td&gt;
&lt;td&gt;&lt;code&gt;app/api/webhooks/kinde/route.ts&lt;/code&gt;&lt;/td&gt;
&lt;/tr&gt;
&lt;/tbody&gt;
&lt;/table&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.amazonaws.com%2Fuploads%2Farticles%2F7p9odrlxnc21d6fm7b99.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.amazonaws.com%2Fuploads%2Farticles%2F7p9odrlxnc21d6fm7b99.png" alt="Full system showing a user signing up → hitting the Free Trial plan → Workflow stamps trial_start → token carries trial_start + feature flags → useTrialStatus computes state → three UI outcomes: active (banner + full access), expiring (urgent banner), expired (paywall)" width="800" height="820"&gt;&lt;/a&gt;&lt;/p&gt;

&lt;h2&gt;
  
  
  What Happens When Kinde Adds Native Trial Support
&lt;/h2&gt;

&lt;p&gt;When Kinde ships built-in trial periods — which is on their roadmap — the migration from this approach is straightforward:&lt;/p&gt;

&lt;p&gt;The &lt;code&gt;trialStartWorkflow.ts&lt;/code&gt; gets retired. The trial length configuration moves from your app's constant into the Kinde plan settings. Kinde handles the start date stamping automatically.&lt;/p&gt;

&lt;p&gt;The &lt;code&gt;useTrialStatus&lt;/code&gt; hook gets simplified — instead of reading &lt;code&gt;application_properties.trial_start&lt;/code&gt;, it reads a Kinde-provided trial claim directly from the token. The state logic (&lt;code&gt;active&lt;/code&gt;, &lt;code&gt;expiring&lt;/code&gt;, &lt;code&gt;expired&lt;/code&gt;, &lt;code&gt;converted&lt;/code&gt;) stays identical.&lt;/p&gt;

&lt;p&gt;The &lt;code&gt;TrialBanner&lt;/code&gt;, &lt;code&gt;TrialExpiredPaywall&lt;/code&gt;, &lt;code&gt;FeatureGate&lt;/code&gt;, and webhook handler stay completely unchanged.&lt;/p&gt;

&lt;p&gt;The migration is a one-afternoon job, not a rewrite.&lt;/p&gt;

&lt;h2&gt;
  
  
  Conclusion
&lt;/h2&gt;

&lt;p&gt;In this article, you built a complete free trial system using Kinde's existing billing primitives: a $0.00 Free Trial plan with Pro feature flags, a Workflow that stamps the trial start date on first sign-in, token customization that surfaces that date without a database query, and a &lt;code&gt;useTrialStatus&lt;/code&gt; hook that drives every trial-aware UI decision from one place.&lt;/p&gt;

&lt;p&gt;The result is a trial system with no cron jobs, no database sync issues, no middleware that sometimes runs and sometimes does not. Trial state lives in the token. Your app reads it. The user sees the right thing at the right moment.&lt;/p&gt;

&lt;p&gt;Kinde is free for up to 10,500 monthly active users, no credit card required. Create your account 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=13&amp;amp;device=&amp;amp;adposition=" rel="noopener noreferrer"&gt;kinde.com&lt;/a&gt; and have your trial system running before lunch.&lt;/p&gt;

</description>
      <category>kinde</category>
      <category>tutorial</category>
      <category>webdev</category>
      <category>learning</category>
    </item>
  </channel>
</rss>
