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.
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.
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. Kinde 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.
By the end, you'll have built:
A token broker that every GitHub action passes through, with nothing else in the app calling GitHub
Two deployment modes, one that stores nothing and one that stores a standing key, so you can watch the difference
An OpenAI tool-calling loop that proposes actions and never sees a credential
A live connect flow through Kinde Connected Apps, and a kill switch that cuts the agent off
An audit trail where every brokered action, refusal, and revocation carries a correlation id
A runtime assertion that hunts for a stored credential after a real agent run
An end-to-end script that walks the whole story and exits non-zero when any check fails
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.
Table of Contents
- What You'll Build
- Prerequisites
- How the Demo Is Structured
- Why a Stored Key Fails
- The Connected Apps Primitive
- How to Connect a User's GitHub
- How to Build the Broker
- How to Model the Data
- How to Decide the Storage Mode
- How to Build the Agent Loop
- Two Revocation Levers, and One Is a Decoy
- The Central Finding: Revocation Reaches the Connection, Not the Token
- The Two Modes, Side by Side
- How to Keep Least Privilege
- What the Operator Controls
- How to Harden It for Production
- How to Prove the Tests Can Fail
- Honest Limitations
- What This Design Costs
- What You've Built, and Where to Take It
- Wrapping Up
- Resources
What You'll Build
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.
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.
That run posted a real comment on a real issue, written by the signed-in GitHub account, while the app stored nothing.
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.
Prerequisites
To follow along, you'll need:
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.
A Kinde account. 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.
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.
An OpenAI API key, and a model that supports tool calls.
A Convex account for the data store. The free tier is enough.
Basic familiarity with OAuth helps but is not required, since the whole point of this build is that Kinde runs that flow for you.
How the Demo Is Structured
Every GitHub action follows the same path, and the storage mode decides only where the credential comes from:
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
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.
Why a Stored Key Fails
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 (OpenAI). Workspace agents go further, because they run repeatable workflows, use connected apps, and complete tasks end to end (OpenAI). Agents act inside a user's own third party accounts today, so the credential question stops being a detail you can defer.
Two of the costs are measurable, and this build measured both.
The stored key carries far more access than the work needs. A token that a developer already had is the token that gets used. In this build the stored key carried gist, read:org, repo, and workflow. The four actions in the demo need public_repo and nothing else. The broker recorded that gap on every action, in the audit row itself.
Central revocation does not reach it. 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.
The fix is not a better column, or tighter access control on the column. The fix is to stop holding the credential.
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.
The Connected Apps Primitive
Kinde Connected Apps let your users reach third party tools through Kinde (Kinde docs). Kinde holds the GitHub authorization. Your app asks Kinde for a token when it needs one.
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.
| Operation | Method and path | Parameters |
|---|---|---|
| Get the auth URL | GET /api/v1/connected_apps/auth_url |
key_code_ref (required), user_id, org_code, override_callback_url
|
| Get a token | GET /api/v1/connected_apps/token |
session_id (required) |
| Revoke | POST /api/v1/connected_apps/revoke |
session_id (required) |
The token response carries two fields and nothing else: access_token and access_token_expiry. The schema has no refresh token field. My app never receives one.
Note the first parameter name. It is key_code_ref, 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.
Your app keeps one value from this flow: the session_id. 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.
In the Kinde dashboard, the connected app carries a name, the GitHub client id, the GitHub client secret, and a key. That last field is the key_code_ref your code sends, and here it reads github.
The Permissions tab is where least privilege gets set. The Scopes box holds one chip, public_repo, and every other GitHub scope below it stays switched off.
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.
The user sees GitHub's own consent screen, and it asks for exactly what Kinde requested.
The Repositories row reads "Public repositories" and nothing else, which is the public_repo 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.
How to Connect a User's GitHub
The connect flow has three parts, and your app stores one value from it.
First, ask Kinde for an authorization URL for this specific user.
export async function getConnectedAppAuthUrl(options: {
kindeUserId: string;
overrideCallbackUrl?: string;
token?: string;
}): Promise<ConnectedAppAuthUrl> {
const env = kindeManagementEnv();
const response = await managementRequest<{
url?: string;
session_id?: string;
}>({
path: AUTH_URL_PATH,
query: {
key_code_ref: env.KINDE_GITHUB_CONNECTED_APP_KEY,
user_id: options.kindeUserId,
override_callback_url: options.overrideCallbackUrl,
},
token: options.token,
});
if (!response.ok || !response.body?.url || !response.body?.session_id) {
throw new Error(
`Kinde did not return a connected app auth url (HTTP ${response.status}).`,
);
}
return { url: response.body.url, sessionId: response.body.session_id };
}
Second, send the user to that URL. GitHub shows its own consent screen. The URL that Kinde generated for my demo requested scope=public_repo and nothing more, and GitHub's screen showed "Public repositories" only.
Third, Kinde returns the user to your callback with the session handle in the query string:
/api/connect/github/callback?session_id=dc5e2e23f19749f08c1f73b4ee7af8c2
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.
await convex.mutation(api.gateway.markLinked, {
secret,
userId: operator.userId,
kindeConnectionId: kindeManagementEnv().KINDE_GITHUB_CONNECTED_APP_KEY,
kindeSessionId: sessionId,
grantedScopes: ["public_repo"],
});
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.
How to Build the Broker
The broker is one function. Every GitHub action in the app passes through it. Nothing else in the app calls GitHub.
Start with the credential step, because that is where the two modes differ.
export async function acquireConnectedAppToken(
source: ConnectedAppSource,
): Promise<CredentialResult> {
// Fail closed before Kinde is even asked.
if (source.status !== "linked") {
return {
ok: false,
status: 0,
reason: `The GitHub connection is ${source.status}. Refusing to act.`,
};
}
if (!source.kindeSessionId) {
return {
ok: false,
status: 0,
reason: "No connected app session for this user. Refusing to act.",
};
}
const result = await fetchConnectedAppToken(source.kindeSessionId);
if (!result.ok) {
// INVALID_SESSION lands here after a revocation.
return { ok: false, status: result.status, reason: result.reason };
}
return { ok: true, token: result.token.accessToken, source: CONNECTED_APP };
}
Now the stored key path. Read what it does not do.
export function acquireStoredKeyToken(): CredentialResult {
try {
return {
ok: true,
token: storedKeyEnv().GITHUB_STORED_TOKEN,
source: STORED_KEY,
};
} catch {
return {
ok: false,
status: 0,
reason:
"STORAGE_MODE is stored-key but GITHUB_STORED_TOKEN is not set. Refusing to act.",
};
}
}
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.
The broker binds the token into a call function and hands the call function to the action handler.
export function createGitHubClient(token: string): GitHubCall {
return async function call<T>(path, options = {}) {
const response = await fetch(url, {
method: options.method ?? "GET",
headers: {
authorization: `Bearer ${token}`,
accept: "application/vnd.github+json",
"x-github-api-version": "2022-11-28",
"user-agent": USER_AGENT,
},
cache: "no-store",
signal: AbortSignal.timeout(GITHUB_TIMEOUT_MS),
});
// ...
};
}
The token lives inside the closure. A handler receives call, not the token. A handler therefore cannot read, copy, log, or store a credential, because it never holds one.
The broker then runs the action and writes an audit row.
// 4. Act. The handler is given a bound caller, never the token.
const target = gitHubTargetEnv();
const call = createGitHubClient(credential.token);
const handler = getHandler(action.id);
const result = await handler(call, input as never, {
owner: target.GITHUB_TARGET_OWNER,
repo: target.GITHUB_TARGET_REPO,
});
await audit("action.invoked", "allowed", result.summary, user._id);
// The token goes out of scope here. Nothing wrote it anywhere.
Here is the whole path, including every way it can end.
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.
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.
How to Model the Data
The store has six tables: users, connections, actions, runs, run events, and the audit log.
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.
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.
it("holds no field that could store a GitHub credential", () => {
const forbidden =
/(access_?token|refresh_?token|^token$|bearer|secret|credential|client_?secret|api_?key|private_?key)/i;
for (const [table, definition] of Object.entries(tables)) {
const names = [...fieldNames(definition.validator.json)];
expect(names.length).toBeGreaterThan(0);
const offenders = names.filter((name) => forbidden.test(name));
expect(
offenders,
`table "${table}" declares credential-shaped fields: ${offenders.join(", ")}`,
).toEqual([]);
}
});
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.
The audit log carries the outcome of every decision. Six event types cover the whole surface: connection.linked, connection.revoked, token.brokered, token.refused, action.invoked, and action.refused. Three outcomes describe what happened: allowed, refused, and failed.
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.
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.
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 token.brokered rows:
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.
18:25:50 action.invoked allowed connected-app Read issue #1 ...
18:25:59 token.brokered allowed connected-app Fetched a token from Kinde for one action. Not stored.
18:26:01 action.invoked allowed connected-app Commented on issue #1 as sholajegede.
Fetch per action stops being a claim in your README. It becomes a pattern in your data that anybody can count.
How to Decide the Storage Mode
The mode must not be something a request can influence. I put the whole rule in one pure function.
export function resolveStorageMode(raw: string | undefined | null): StorageMode {
return raw === STORED_KEY ? STORED_KEY : CONNECTED_APP;
}
Only the exact string stored-key selects the unsafe mode. Every other value resolves to connected-app. That covers unset, empty, STORED-KEY, stored_key, a trailing space, and anything an attacker injects.
The only caller reads process.env.STORAGE_MODE and nothing else. No request, header, cookie, or query parameter appears anywhere in that path. The reader also throws if it ever runs with window defined, so a client component cannot pull it into the browser bundle.
How to Build the Agent Loop
The agent proposes actions. The broker performs them. The agent holds no credential.
The tool list comes from the action registry, so the agent cannot be offered a capability that the registry does not define.
export function agentTools(): AgentTool[] {
return listActions().map((action) => ({
type: "function",
name: action.id,
description: describe(action),
parameters: toJsonSchema(action.input),
strict: false,
}));
}
The loop reads the model id from configuration. It never appears as a literal in the source.
const response = await openai.responses.create({
model,
input: conversation,
tools,
});
const requested = response.output.filter(
(item): item is Extract<typeof item, { type: "function_call" }> =>
item.type === "function_call",
);
for (const toolCall of requested) {
await emit("agent.tool_requested", `Requested ${toolCall.name}.`);
// The one and only path to GitHub.
const outcome = await brokerAction({
actionId: toolCall.name,
input,
kindeUserId: request.kindeUserId,
correlationId,
runId,
});
// ...
}
I use the Responses API rather than chat completions for a measured reason. The model family I tested rejects function tools on /v1/chat/completions unless reasoning is switched off:
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
Staying on chat completions would have meant disabling the model's reasoning to get tools. The Responses API carries both.
When the broker refuses for lack of a credential, the agent stops. It does not retry, and it does not reach for another tool.
// Fail closed. A credential refusal will not resolve itself, so the
// run stops here rather than looping or reaching for another route.
if (outcome.status === "refused" && outcome.refusal === "credential") {
halt = true;
status = "refused";
finalMessage = `Stopped: ${outcome.reason}`;
}
The broker returns a typed refusal kind so this check is structural, not a string match.
| Refusal kind | Meaning | Caller behaviour |
|---|---|---|
credential |
No token now, and none coming | Stop |
input |
The arguments failed validation | Correct and retry |
unknown-action |
The name is not in the registry | Stop |
no-user |
No such user | Stop |
I drew that line on purpose. An input 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.
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.
Two Revocation Levers, and One Is a Decoy
The Kinde Management API exposes two things that sound like a kill switch. Only one is.
I tested both against the live service.
Lever A: DELETE /api/v1/users/{user_id}/sessions.
DELETE sessions: HTTP 200 in 420ms
{"code":"USER_SESSIONS_INVALIDATED","message":"User sessions successfully invalidated"}
Then I polled the token endpoint every two seconds:
+ 2303ms STILL ISSUING HTTP 200 (fingerprint 7ea1fb9d9fa2)
...
+ 60011ms STILL ISSUING HTTP 200 (fingerprint 7ea1fb9d9fa2)
Kinde was still issuing tokens 60000ms after revocation, across 23 attempts.
Twenty three consecutive requests over a full minute. Every one returned HTTP 200. Every one returned the same token.
The endpoint reports success and stops nothing. The reason shows in the data. Before the test, GET /users/{id}/sessions returned "sessions": [], 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.
If you build a kill switch on this endpoint, it returns HTTP 200, it logs success, and it cuts off nothing.
Lever B: POST /api/v1/connected_apps/revoke.
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
The very next request failed. Not eventually. The first call after the revoke returned HTTP 400 with INVALID_SESSION.
This is the kill switch. I pinned it in code and in a test.
export async function revokeConnection(
request: RevocationRequest,
): Promise<RevocationResult> {
// ...
const result = await revokeConnectedAppSession(connection.kindeSessionId);
if (!result.ok) {
const reason = `Kinde refused the revocation (HTTP ${result.status}).`;
await audit("failed", reason, user._id);
return { status: "failed", reason, httpStatus: result.status };
}
// Record the revocation locally too. The broker refuses on its own
// authority once the connection reads `revoked`, rather than depending on
// Kinde's refusal alone.
await convex.mutation(api.gateway.markRevoked, { secret, userId: user._id });
The test asserts that the revoke module calls revokeConnectedAppSession, never mentions deleteUserSessions, 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.
The Central Finding: Revocation Reaches the Connection, Not the Token
Now the result that matters most, and the one that weakens the easy version of this story.
After connected_apps/revoke succeeds and Kinde refuses to broker, does a token that Kinde already handed out still work at GitHub?
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.
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
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.
Revocation at Kinde does not reach into GitHub. It stops Kinde from brokering and from refreshing. It does not cancel an outstanding token.
So the instant cutoff in connected-app mode is real, but the reason is not the one you might assume. The cutoff is instant because the broker keeps nothing between actions. The next action must ask Kinde. Kinde refuses. The action stops. There is no credential left inside the app for anything to keep using.
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.
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.
The Two Modes, Side by Side
I ran both modes against the same revocation and recorded what happened.
The revocation landed at 18:41:52 UTC. Here is the audit log across both modes, ordered by time.
time mode event outcome detail
18:41:31 connected-app action.invoked allowed Commented on issue #3 as sholajegede.
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.
18:46:32 stored-key action.invoked allowed Commented on issue #2 as sholajegede.
The stored-key comment is real. It has a GitHub comment id and a created_at of 2026-08-14T18:46:31Z, which is 4 minutes and 39 seconds after the revocation. An agent that the operator believed they had cut off carried on writing to the repository.
Every stored-key audit row says why, in the row itself: "Used the token the app holds. Kinde was not consulted."
The connected-app 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 linked while the Kinde session stayed revoked, and ran again:
3 broker.refused INVALID_SESSION: Error encountered while retrieving tokens for connected app
INVALID_SESSION 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.
After the revoke. The GitHub connection is cut. The operator stays signed in to the app.
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 revoked. The user keeps their session with your product and loses only the third party connection they chose to cut.
Ask the agent for something after the revoke, and the run stops on the first tool call.
The agent asks for one tool, the broker refuses, and the run halts. No retry, and no second route.
How to Keep Least Privilege
The registry defines four actions and nothing else. The agent cannot name its way to a capability that does not exist, because getAction() throws on anything unregistered.
| Action | Effect | Acts as the user | Scope |
|---|---|---|---|
read_issues |
Reads | No | public_repo |
read_issue |
Reads | No | public_repo |
comment_issue |
Writes | Yes | public_repo |
open_pr |
Writes | Yes | public_repo |
I first wrote the registry with public_repo and read:user, so the audit trail could record who acted. Then I hit a real constraint. Kinde's GitHub scope picker does not offer read:user. The connection grants public_repo and nothing else.
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.
export function actingUserFrom(payload: unknown): ActingUser | null {
if (!payload || typeof payload !== "object") return null;
const body = payload as MaybeUser;
// A nested `user` is the shape returned by comments and pull requests.
const candidate =
typeof body.user?.login === "string"
? body.user
: typeof body.login === "string"
? body
: null;
if (!candidate || typeof candidate.login !== "string") return null;
return {
login: candidate.login,
id: typeof candidate.id === "number" ? candidate.id : null,
};
}
A comment response carries { user: { login } }. GET /user 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.
The broker also compares the scopes GitHub reports against what the registry claims, and records both directions. In connected-app mode the audit row reads:
Read 2 open issue(s) in sholajegede/connected-apps-github-sandbox.
Least privilege holds: the credential carries exactly what the action needs.
In stored-key mode the same code reads:
Over-privileged: the credential also carries gist, read:org, workflow,
which this action does not need.
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.
What the Operator Controls
The demo gives the user the controls rather than a video of somebody else using them.
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.
The console shows five counts, and each one answers a question a reader will ask.
| Count | What it answers |
|---|---|
| Tokens supplied | How often the broker asked Kinde |
| GitHub tokens that the app keeps | Zero in connected-app, one in stored-key
|
| Actions done on GitHub | What really happened in the repository |
| Actions refused | How often the broker said no |
| Actions after revocation | Zero in connected-app, above zero in stored-key
|
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.
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 connected-app, and red at a real number in stored-key.
How to Harden It for Production
A demo becomes trustworthy when the failure paths behave.
Give every outbound call a deadline. 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:
outcome refused
reason Kinde did not answer within 8000ms.
audit: token.refused refused connected-app Kinde did not answer within 8000ms.
Before I added this, that path threw past the broker with no audit row at all. 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.
Refuse an action you cannot record. The broker writes the token.brokered row before it calls GitHub. If that write fails, the action does not happen.
if (!recorded) {
const reason =
"The audit trail is unavailable. Refusing to act rather than act without a record.";
return {
status: "refused",
actionId: action.id,
storageMode: mode,
reason,
refusal: "credential",
};
}
The row also goes to an append only local queue first, so the attempt stays recoverable rather than lost.
Never log a token. The broker redacts every GitHub token shape from any text it records, and reports tokens by fingerprint.
const CREDENTIAL_PATTERN =
/gh[pousr]_[A-Za-z0-9]{16,}|github_pat_[A-Za-z0-9_]{16,}/g;
export function redactCredentials(text: string): string {
return text.replace(CREDENTIAL_PATTERN, "[redacted]");
}
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.
Prove the claim at run time. 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.
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.
The same script run in stored-key mode reports FOUND GitHub OAuth token (gho_) in env.GITHUB_STORED_TOKEN, and stays clean everywhere else. The same assertion, the same code, and a different answer per mode. That contrast is the point.
One detail makes this proof meaningful. The stored key lives in a separate file that the app loads only when the mode is stored-key. If it sat in the main environment file, it would be in process.env on every run, and the environment scan would be worthless.
How to Prove the Tests Can Fail
A green test suite means nothing until you have seen it go red.
The end to end script walks the whole story in one pass: clean slate, a connected-app run that comments on a real issue, a revoke, a refused run, a stored-key run after the same revoke, and a reconciliation across both modes. It asserts real outcomes and exits non-zero on any failure.
Each acting step runs in a child process with its own STORAGE_MODE. The mode is a deployment decision, and a script that flipped it in its own process would quietly contradict the property it tests.
Green looks like this:
GREEN — every assertion held. 69.3s
Then I inverted the load bearing check. Step 4 asserts that the stored-key agent still acts after the revocation. I changed status === "succeeded" to status !== "succeeded", so the assertion demanded the opposite of the measured truth:
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
status=succeeded — Reviewed the open issues and commented on issue #1 ...
EXIT CODE = 1
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.
Honest Limitations
Read these as written. Do not soften them.
Revocation does not cancel a token that GitHub already issued. 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.
The token life is real, and it depends on one setting. The brokered token is an opaque 40 character gho_ token. It is not a JWT, so there is nothing to decode. Kinde reported an expiry of 2026-08-15T02:09:08.871+01:00, which was 28761 seconds ahead, or 7.99 hours. That matches GitHub's documented eight hour user access token (GitHub docs). GitHub issues it that way because the OAuth app has Token expiration switched on, under Optional features. GitHub's own wording for that setting states the eight hours, and states something else worth reading twice.
"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.
Kinde returns the same token again, not a new one. Two requests inside the same eight hour window returned the identical token, confirmed by fingerprint:
fingerprint 7ea1fb9d9fa2
second fetch fingerprint 7ea1fb9d9fa2 — SAME token replayed
So the correct claim is fetch per action, store never. The claim is not that each action gets a fresh token. The security property comes from the app holding nothing between actions.
Kinde holds the refresh token. 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.
The read queries in my demo use an id, not access control. 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.
What This Design Costs
Be honest about the trade you make.
You add a network call to every action. 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.
You add a dependency to the hot path. 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.
You need somewhere to put the session handle. 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.
You give up one convenience. 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.
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.
What You've Built, and Where to Take It
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.
The shape transfers to any third party API, not only GitHub. Eight things are worth carrying over.
Put one function between your code and the third party. 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.
Give handlers a bound caller, not the credential. If a function never holds a token, it cannot leak one. This removes a whole class of review question.
Decide the mode on the server, from deployment configuration. Make the unsafe value an exact match and let everything else fall to the safe one. Then a typo fails safe.
Make a refusal a normal result. The broker returns a typed outcome instead of throwing. Callers handle refusal as data, and the audit trail records it like anything else.
Distinguish refusal kinds. 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.
Fetch per action and keep nothing. This is the part that makes central revocation bite. If you cache, you reopen the window.
Test the outage, do not reason about it. Point the issuer URL somewhere unroutable and see what your code does. I found a real bug that way.
Write down the limitation that hurts. 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.
Wrapping Up
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.
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.
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 Kinde.
Resources
Source code
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.
Core documentation
-
Kinde: add connected apps: the
auth_url,token, andrevokeflow this build uses - GitHub: refreshing user access tokens: the eight hour token life and the refresh token
- GitHub: token expiration and revocation: what expires, and what a user can revoke themselves
- GitHub: authorizing OAuth apps: the consent screen the user sees
Context for the problem
- OpenAI: company knowledge in ChatGPT: connectors for Slack, SharePoint, Google Drive, and GitHub
- OpenAI: workspace agents in ChatGPT: agents that run workflows across connected apps
Build tooling
- Next.js App Router: the server actions and route handlers the console uses
- Convex: the data store behind the audit trail and the live timeline
- OpenAI Responses API: the tool-calling endpoint the agent loop uses
- Vitest: the runner behind the structural tests












Top comments (0)