Connecting my issue tracker to my agent runner took seven steps and two secrets copied between browser tabs: make an agent member in one app, copy its API key into the other, make a webhook there, copy its signing secret back. Every copy is a place for a secret to end up in a clipboard manager, a screenshot or a chat message.
I replaced it with one button. Neither secret is ever in a page, an address or a clipboard. This is how the handoff works, and the two things that went wrong the first time I connected the halves.
The shape: the PKCE idea, outside OAuth
I wrote both apps, so I could design both ends. The flow borrows from OAuth's PKCE, but there is no OAuth server here:
- The asking app (the agent runner) makes a random verifier and keeps it on its server. It sends the admin's browser to the tracker's approval page with a challenge: the SHA-256 of the verifier. The verifier never goes to the browser.
- The admin signs in to the tracker, sees the asking origin in large type and what Allow will create, and presses Allow.
- The tracker makes the agent member and the webhook, stores the secrets, and sends the browser back with a one-time code in the address.
- The asking app's server posts
{ code, verifier }to the tracker, server to server. The tracker hashes the verifier, compares it with the stored challenge, and answers with both secrets.
The browser only ever carries a hash and a code. A code read from a log, a Referer header or the address bar is useless without the verifier. The challenge is what binds the code to the server that started the flow:
// Simplified from the exchange handler. const exchangeBody = z.object({ code: z.string().regex(/^[A-Za-z0-9_-]{43}$/), verifier: z.string().regex(/^[A-Za-z0-9._~-]{43,128}$/), }); // later: the hash of what the caller sent must match what the row holds if (!sameText(await challengeOf(verifier), keep.challenge)) return json(NO_CODE, 400); Secrets at rest wait under a key made from the code
For 60 seconds the tracker keeps a row holding the challenge in the clear and everything else sealed with AES-GCM: both secrets, the ids, an undo token. The row's identifier is track-connect: plus the SHA-256 of the code, never the code. The key is HKDF of the server's secret, salted with the code:
// Simplified. crypto.subtle.deriveKey( { name: "HKDF", hash: "SHA-256", salt: new TextEncoder().encode(code), info: new TextEncoder().encode("track-connect-code-v1") }, serverSecret, { name: "AES-GCM", length: 256 }, false, ["encrypt", "decrypt"], ); So a copy of the table opens nothing, because it has neither the code nor the server secret, and the code alone opens nothing either. The row's identifier and the challenge are bound in as additional data, so the seal won't open under a challenge someone with write access to the table swapped in.
The delete decides
The part I like most is the exchange's first move. It doesn't read the row and then delete it. It deletes by identifier and takes the row from the RETURNING:
const [row] = await db .delete(schema.verification) .where(eq(schema.verification.identifier, identifier)) .returning() .all(); if (!row || row.expiresAt.getTime() <= Date.now()) return json(NO_CODE, 400); Whoever's statement removes the row is the one that goes on. Of two exchanges at once, exactly one gets a row. And because the delete comes before the expiry check, the challenge check and the decryption, a code that fails any of them is spent anyway: a wrong guess at the verifier burns the code. Every failure, including a body of the wrong shape, returns the same 400 invalid_code.
Both or neither, and a signed way back
On Allow, the tracker makes the agent member, then the webhook for it. If the webhook fails, or the code can't be stored, it deletes the agent again, and if that fails too, the message names the member to remove by hand.
The exchange also returns an undo token: workspace, agent and webhook ids plus an expiry ten minutes out, signed with HMAC-SHA-256 under a key derived with its own purpose string. If the asking app's own setup fails after the exchange, it posts the token to an undo endpoint, which removes the webhook, then the agent. It answers 200 whether or not they were still there, so a token tells you nothing about what is left.
The asking side keeps its own pending connection while the browser is away: a random state, bound to the person who started it, good for ten minutes, and taken once. "Taken" means deleted by the first call that names it, even if that call is by the wrong person. A link someone else follows spends it and makes nothing.
What went wrong when the halves met
Each half had passing tests. The first real connection still failed in two ways.
1. A dot in the state. The asking app needed to know which workspace a returning browser belonged to, so it put the workspace id in the state: <workspaceId>.<token>. The tracker's approval page accepts only letters, digits, _ and - there, so it refused every request. All the tests had passed because the fake tracker in them validated nothing. The fix moves the workspace into the return address's own query (the tracker keeps a return address's query and adds to it), and the fake now applies the same checks as the real page:
if (!/^[A-Za-z0-9_-]{1,200}$/.test(query.get("state") ?? "")) { throw new Error("The request's state is missing or malformed."); } A test double that is more lenient than the thing it stands for is a bug with good coverage numbers.
2. A notice that was false. The tracker creates the member and the webhook on Allow, before the exchange. If the exchange then fails (refused, slow, unreachable, unreadable), the asking app has no undo token, because the token arrives in the exchange's answer. My error notice still said "Nothing was made." It now says what may still be in the tracker and where to remove it. I had written a sentence that was true only on the paths I'd thought about.
An open end
If someone closes the browser after Allow and the code is never traded, the member and the webhook stay. The code expires after a minute, but nothing sweeps what was made for it. The admin can delete them in Settings, but nothing does it for them yet. A sweeper for connections whose code was never exchanged is the obvious fix, and I haven't written it.
Was it worth it?
Yes, mostly because the seven steps are now one, and no secret passes through a place I can't account for. The cost was a second small protocol to design and test: two endpoints, a sealed row, a signed token and a pending state on each side. If your two apps are both yours, I'd do it again. If one belongs to someone else, use real OAuth.
How do you hand a secret from one of your services to another today? Copy and paste, a shared vault, something cleverer?
The tracker's half is in apps/web/src/server/connect.ts if you want to read it.
I drafted this with an AI assistant (Claude), which also wrote much of the code it describes. I checked the facts and numbers against the code.