How to Auto-Revoke a Claude Agent's Access When a User Is Offboarded With Kinde Webhooks
DEV Community

How to Auto-Revoke a Claude Agent's Access When a User Is Offboarded With Kinde Webhooks

Imagine this scenario, someone on your team gets offboarded while their AI agent is still mid-task. Nobody remembers the agent is even running. It's just doing what it was told, on behalf of someone who, as of a minute ago, doesn't work at your company or on your team anymore. Does it stop? Well, I built a small app to test that scenario, using Kinde to handle sign-in and to hold the record of who's still active. I signed a real user in, handed their agent a task, and while the agent was still working through it, suspended that same user, in Kinde's dashboard, mid-run, to see what the agent would do next. Unsurprisingly, the agent kept working on the task. You see, suspending or deleting a person changes how Kinde itself sees that user, but it doesn't touch the access token their agent is already holding, because nothing about a suspension reaches back into a token that was already signed and handed out before it happened. The token still verifies exactly as it did before the suspension, so the agent has no way to know anything changed. Everything I am going to talk about in this article is about closing that gap, and about what I actually found while doing it: webhook deliveries measured live, a production bug that could have left an offboarded user's record looking active forever, and a hard number for how long an offboarded person's agent keeps acting before anything catches it. Why a suspended user's token still works Let's start with what an access token actually is, because the whole gap follows from it. An OAuth access token isn't a receipt you hand back to check against a ledger. It's a signed claim, a small JSON payload with a cryptographic signature attached, and whatever's checking it just verifies that signature against a public key rather than calling home to ask if the token's still good. That's the entire appeal of the design: an API can confirm a token is genuine without a database round trip on every request. Which means suspending a user in Kinde only changes a row in Kinde's own database. It doesn't reach the token at all, because there's nothing there for it to reach: the token was already handed out, already signed, already valid until whatever expiry it was minted with. Revoking it properly would mean tracking every issued token in a lookup table somewhere, which throws away the entire point of signing one in the first place, or it would mean just waiting for the thing to expire on its own. I suspended a signed-in test user mid-session, and the app's own check kept reporting that user's access token as valid, seconds after Kinde had already suspended them. So that gap isn't a bug in Kinde, and it isn't a bug in OAuth either. It's just what a stateless credential is, by design, and the real question is what you build on top of it. The shape of the fix Two pieces close the gap. A webhook tells the app when Kinde's view of a user changes, and a check runs before every single agent action, reading the app's own record of that user instead of trusting whatever was true when the session started. The agent itself is a small Claude Messages API loop, working through a closed set of three tools against a demo set of internal resources: list_resources , read_resource , write_resource . None of what follows is specific to what the agent does. It's specific to the one place every tool call has to pass through before it's allowed to run at all. Building the seam First, we start with the registry that defines those three actions, because it's closed by construction rather than by convention. An action that isn't in this table doesn't half-exist somewhere in the code, waiting to be called by accident. It just doesn't exist: export const ACTION_REGISTRY: Record = { list_resources: { name: "list_resources", destructive: false, params: {} }, read_resource: { name: "read_resource", destructive: false, params: { resourceId: { type: "string", required: true } }, }, write_resource: { name: "write_resource", destructive: true, params: { resourceId: { type: "string", required: true }, title: { type: "string", required: false }, body: { type: "string", required: false }, }, }, }; Both the tool schema handed to Claude and the enforcement check are built from this same table, so the two can never quietly drift apart from each other the way a schema and a permissions list usually do once someone forgets to update one of them. Every tool call the model makes passes through a single function, enforceToolCall , which looks up the acting user's current status and hands it to a small, pure decision function underneath it: export function decideAccess(input: { mode: EnforcementMode; userStatus: UserStatus; }): { decision: SeamDecision; reason: SeamReason } { if (input.mode === "naive") { return { decision: "allow", reason: "naive_mode_no_check" }; } if (input.userStatus === "active") { return { decision: "allow", reason: "user_active" }; } if (input.userStatus === "offboarded") { return { decision: "refuse", reason: "user_offboarded" }; } return { decision: "refuse", reason: "user_unknown" }; } Naive mode allows every call without ever looking at that status, which is the vulnerability this whole piece is about, reproduced on purpose so both modes can run side by side against the exact same code and prove the point cleanly. Enforced mode is stricter in a way that matters: it allows exactly one case, a confirmed active user, and refuses everything else, including a status the seam couldn't even resolve because a read to Convex failed. An unknown status doesn't get the benefit of the doubt. That status comes from a webhook. Kinde sends a signed event on user.updated and user.deleted , the receiver verifies the signature, and then it does one more thing that has nothing to do with the signature at all: export function isFreshWebhookEvent( event: WebhookEvent, now: number = Date.now(), ): boolean { const eventTime = Date.parse(event.timestamp); if (Number.isNaN(eventTime)) return false; return Math.abs(now - eventTime) <= MAX_CLOCK_SKEW_MS; } A valid signature only proves Kinde signed this payload at some point. It says nothing about when that was, so without this check, a captured event replayed months later would sail straight past signature verification, since the signature itself never expires, and past deduplication too, because dedup only catches an event id it's already seen before. MAX_CLOCK_SKEW_MS is five minutes. Anything older than that gets rejected the same way a forged signature would. The bug the build actually found Hardening this receiver turned up a real ordering bug. The first version recorded the webhook's delivery, for deduplication, before it applied the actual effect of marking the user offboarded. That ordering has a quiet failure mode: if the effect write failed right after the delivery had already been logged, a retried webhook would look like a duplicate of one already handled and get skipped. The user would never actually get offboarded, and nothing about the system would ever try again. The fix took one line of reordering, but it only works because of one property underneath it: the effect, markOffboarded , is idempotent, so running it twice is always safe. That's why it now runs first, unconditionally, ahead of the bookkeeping whose entire job is to stop it from running a third or fourth time. Recording the delivery first and applying the effect second felt like the more natural order to write. It was also the less safe one. Proving it, live scripts/e2e-narrative.ts runs one task against one real Kinde test user, twice: once with the seam in naive mode, once enforced, suspending that same user for real, mid-run, both times. Nothing in this script is simulated. It drives a real agent loop, fires a real suspend call at Kinde, and waits on the actual webhook to arrive over a tunnel before it checks what actually happened. | naive | enforced | | |---|---|---| | actions allowed after offboarding | 2 | 0 | | where the run stopped | it didn't, ran to completion | step 2, reason user_offboarded | The same story shows up in the operator console, so I ran it once more while writing this, offboarding the signed-in user on purpose partway through a task: Step one lands while the user's still active. The offboard request goes out. Step two refuses, user_offboarded , with a cutoff latency of 2697ms measured from when the offboarding itself landed in the database, not from when the run started. Every one of those decisions lands in an audit log under a shared correlation id, so a run's full timeline can be pulled back up after the fact, not just watched live: The numbers that matter more than the demo Across every live webhook delivery in this build, latency ran from 652ms to 2058ms, across suspends, restores, deletes, and role changes alike. That's not instant. Most people assume it is. The enforcement check itself doesn't belong anywhere in that number. It's one indexed read against Convex, and next to webhook delivery it's close enough to free that it doesn't move the total. So the more honest way to describe total revocation speed is this: it's webhook delivery latency, plus however long until the agent gets around to its next real action, and that second part isn't a fixed system number at all. An agent moving faster, with no artificial pacing between steps or several tool calls requested in the same turn, gets caught just as fast on its very next call, without any extra mechanism needed to catch it sooner. The floor here is roughly one model round trip, not the enforcement check sitting underneath it. A webhook can also be missed or delayed, because that's what "best-effort delivery" actually means in practice, so I built a reconciliation sweep on top: a cron job checking every active user's live status directly against Kinde every five minutes. I tested it against a forced scenario: suspended a real user, then manually pushed the app's own record back to active, simulating a webhook that never arrived

Read on DEV Community ↗ ← Back to News

Comments

No comments yet. Start the discussion.