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

작성자

카테고리:

← 피드로
DEV Community · Shola Jegede · 2026-09-17 개발(SW)

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.

A flowchart showing how Kinde user suspensions propagate through signed webhooks and reconciliation into Convex, with an enforcement seam controlling Claude agent tool calls.

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<ActionName, ActionDefinition> = {
  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 },
    },
  },
};

Enter fullscreen mode Exit fullscreen mode

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" };
}

Enter fullscreen mode Exit fullscreen mode

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;
}

Enter fullscreen mode Exit fullscreen mode

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.

Kinde's suspend and restore admin action panel

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:

The console's live timeline: step 1 allowed while active, step 2 refused after offboarding

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:

Convex's auditLog table, showing real seam decisions and webhook deliveries

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 at all. The cron caught the drift and corrected it on its own, before I ever triggered a manual run.

Here’s what I think

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

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

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

What this doesn’t solve

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

The reconciliation sweep is a five-minute backstop, not the primary path, and if the webhook’s doing its job, the sweep never finds anything to correct.

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

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

Where this leaves the agent

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

Code and sources

The full build, the enforcement seam, the webhook handler, the reconciliation cron, and scripts/e2e-narrative.ts, is on GitHub: sholajegede/offboarding-revocation-demo.

The webhook signing and delivery model comes from Kinde’s own webhooks documentation, and the suspend and restore actions used throughout the demo follow Kinde’s Management API and its notes on suspending and deleting users.

The reconciliation sweep runs on Convex’s cron jobs, and the agent loop follows Anthropic’s tool use documentation for the Messages API. Every number in this piece came from runs.timeline and auditLog in that same Convex deployment, read straight off the live runs.

Clone it, wire it up against your own Kinde tenant, and offboard a test user mid-run. Your webhook latency might not match mine, your reconciliation sweep could catch different drift, and that’s the point really. Drop your numbers in the comments and let me know what you find.

원문에서 계속 ↗