Five days ago I published a kill switch for a LangGraph agent: a tool-call interceptor, a watchdog, an anomaly detector, and a manual override. Three of the four run inside the agent's own operating system process, though still outside the part of it the model's own output can influence. The fourth runs outside the process entirely, so that it can kill it. Every one of them is aimed at a single running graph.
Now dispatch fifty sub-agents from that graph, each on its own worker, each holding a token minted an hour ago. Kill the orchestrator, then revoke every token you can find. Your interceptor dies with the process. And the watchdog reports nothing at all, because it infers behavioural loops from heartbeats, and a dead process sends none. Meanwhile fifty workers that never read your credential store keep opening pull requests against your production repository, and every one of those requests carries a signature your identity provider will happily verify.
That tutorial's own closing section admits the weak point: its CredentialStore is an in-memory dictionary, and a production version "revokes against a real identity provider or a short-lived-token issuer." I wrote that. Swapping the dictionary for Entra or AWS Security Token Service would not have saved the fifty workers above, and the reason has nothing to do with which of them you pick.
One thing up front, because it changes how you should read the rest: that scene is constructed, not retold. I could find no public incident in which a revoked credential was demonstrably used by an agent to ship code, and I say more about that near the end. The mechanism below is specified well enough to reason about without a body on the floor, and I would rather you knew that in paragraph three than in section eleven.
GitHub already shipped this bug, then shipped a second stop to fix it
This failure is older than agents. Press cancel on a GitHub Actions workflow and the server does not terminate your jobs. It re-evaluates the if conditions on every running job and unfinished step, and per GitHub's own reference, a job whose condition still evaluates to true keeps running, which for if: always() means every time. Cancel is a signal the job is invited to respect.
On 21 September 2023 GitHub shipped force-cancel, because, in their words, "sometimes an Actions workflow can become stuck in a state that will not respond to a cancel request" and customers had to open a support ticket to get out of it. Force-cancel will "bypass conditions that would otherwise cause the workflow execution to continue."
Read that sequence again. A vendor with full control of the runner, the scheduler, and the API had to build a second, harder stop, because the first stop was a request.
Notice what GitHub's failure is not. A condition re-evaluated at every step boundary is a decision sitting on the path, and the stop still failed. What failed is that the policy at that decision point was authored by the thing being stopped, and a workload that writes if: always() has written itself an exemption. Placement is necessary and not sufficient. The decision has to sit on the path, and the policy evaluated there must not be delegated to the workload. Hold both halves, because the second is the one that is easy to lose while congratulating yourself about the first.
Agent frameworks have the same problem and less leverage. GitHub could ship force-cancel because GitHub owns the runner. Nobody can ship a force-cancel for a signed token being presented by a process they do not own, and no agent platform owns the worker the way GitHub owns its own.
Three different failures are being called zombie agents
That term is doing too much work right now. Use it without saying which sense you mean and half your readers will picture the wrong problem.
| Sense | What happens | Credentials involved? |
|---|---|---|
| Prompt-injection persistence | Injected instructions survive in memory and re-fire on later turns | No |
| Orphaned identity | A service account from a finished project still holds standing access | Yes, but nobody revoked anything |
| Authority outliving revocation | A child authenticates successfully after its parent was revoked | Yes, and revocation was issued |
Radware's ZombieAgent disclosure, reported to OpenAI in September 2025 and made public on 8 January 2026, is the first kind. A zero-click indirect prompt injection writes to ChatGPT's memory so that exfiltration re-triggers on every subsequent message. No OAuth token appears anywhere in it. The second kind is the non-human identity governance problem, and its fix is an inventory plus an expiry policy.
This article is about the third, which arXiv:2605.20704 defines formally: "Agent A_c is a zombie at time t if its parent A_p was revoked at t_r < t yet A_c authenticates successfully." What separates the three is who holds control. In the first case an agent is taking instructions from the wrong principal. In the second, nobody remembered to turn it off. Here, somebody turned it off correctly and it kept going.
The working definition, in one line, because everything below depends on it: a zombie agent is one whose credential still verifies after the authority behind that credential was withdrawn. Not compromised. Not forgotten. Revoked, and still working.
One caution about that paper, which is the most-cited source I could find on this topic. Its opening argues: "The problem is not hypothetical: the ZombieAgent vulnerability is a zero-click attack on ChatGPT agents that persists in memory and exfiltrates data invisibly." Read that against the formal definition above and the two do not line up. Memory persistence is the first sense in the table, the definition is the third, and no credential is revoked in the ZombieAgent case at all. It reads to me as evidence for agent persistence in general standing in for evidence of the specific credential failure, which has, as far as I can find, never been publicly cashed out as a breach.
Revocation is a request. Authorization on the path is a gate
In an earlier piece on Claude Code I named the Enforceability Axis: for any rule you want an agent to follow, is it guidance the model interprets, or a gate it cannot route around? A skill is advisory. A PreToolUse hook is enforced, because it runs on the path of the tool call and exits before the call happens.
Credentials have the same axis, and almost every team sits on the wrong side of it.
You cannot revoke a token that is validated only against its own contents. You can always revoke the authority, but only if something evaluates that authority on the path of every call. A revocation endpoint, a certificate revocation list, a cancel signal, a shorter time-to-live (TTL): none of these sit on the path. They bound how much damage a stopped agent can still do. They do not stop it.
Most teams read a failed stop as a purchasing problem: revocation is something you buy or configure, and yours did not hold because the identity provider was not modern enough. Product maturity is not the variable. Placement is, and it has a correct answer that predates the entire agent discourse.
What RFC 7009 actually promises about token revocation
Most teams reach for RFC 7009, the OAuth 2.0 Token Revocation spec, and read a 200 response as proof the token is dead. Two clauses say otherwise.
The first is an asymmetry stated as a requirement: "Implementations MUST support the revocation of refresh tokens and SHOULD support the revocation of access tokens." Revoke a refresh token and the server should also invalidate the access tokens issued from that grant. Revoke an access token and the server only may invalidate the refresh token. Access-token revocation is the weaker of the two operations, and it is the one most incident runbooks call.
The second is the Implementation Note, which describes your architecture better than most architecture documents do. It sets out two designs and the difference between them is the whole of this article:
"The access tokens may be self-contained so that a resource server needs no further interaction with an authorization server issuing these tokens to perform an authorization decision of the client requesting access to a protected resource. A system design may, however, instead use access tokens that are handles referring to authorization data stored at the authorization server. This consequently requires a resource server to issue a request to the respective authorization server to retrieve the content of the access token every time a client presents an access token."
Then it says which of the two you can actually revoke:
"In the latter case, the authorization server is able to revoke an access token previously issued to a client when the resource server relays a received access token. In the former case, some (currently non-standardized) backend interaction between the authorization server and the resource server may be used when immediate access token revocation is desired."
Read those together and the spec conceded this argument in 2013. Revocation works in the case where the resource server asks on every call. In the case where it does not ask, the RFC offers you "currently non-standardized" backend machinery, which is a polite way of saying you are on your own. It then names the fallback that most teams actually ship: "Another design alternative is to issue short-lived access tokens."
Two designs, one of which is revocable because something asks, and a consolation prize measured in minutes. That is the entire solution space, and it has not moved in over a decade. What moved is the number of things holding tokens.
The Note closes with the sentence I would put above the desk of anyone designing this: "The cost of revocation in terms of required state and communication overhead is ultimately the result of the desired security properties." You do not get revocation for free. You buy it with state and round trips, and the rest of this article is about what the invoice looks like.
Why a signed JWT cannot be revoked
A signed JSON Web Token (JWT) is the self-contained case. It exists precisely so the resource server never has to ask anyone whether it is still good. You chose that property for latency. Exactly that property is what makes the token unrevocable.
Even the spec's timing language hedges. Revocation "takes place immediately," but "in practice, there could be a propagation delay," and implementations "should minimize that window." Minimize, not eliminate.
The wrong way: revoke the tokens and call it a stop
Nearly every agent stop procedure I have read has this shape:
# stop.py - what most teams ship, and what it does not doimport osimport httpxAUTH = "https://auth.internal/oauth2/revoke"CLIENT_ID = os.environ["OAUTH_CLIENT_ID"]CLIENT_SECRET = os.environ["OAUTH_CLIENT_SECRET"]# orchestrator and registry are your own control plane: whatever tracks# running graphs and the children they reported. That reporting gap is# the first of the four failures below.from control_plane import orchestrator, registrydef revoke(token: str) -> None: """RFC 7009 revocation. Returns 200 for a valid token, and also 200 for a token the server has never heard of - the spec requires it.""" response = httpx.post( AUTH, data={"token": token, "token_type_hint": "access_token"}, auth=(CLIENT_ID, CLIENT_SECRET), timeout=5.0, ) response.raise_for_status()def stop_run(run_id: str) -> None: orchestrator.terminate(run_id) for child in registry.children_of(run_id): revoke(child.access_token)Every call returns 200. Your audit log shows fifty successful revocations and one terminated orchestrator. It reads like a clean stop:
# child tokens minted 14:18:00, one-hour lifetime14:22:09 orchestrator run-8814 terminated14:22:09 revoking 50 child access tokens...14:22:13 revoked 50/50 (HTTP 200 x50)14:22:13 stop complete# and, while that progress bar was filling:14:22:11 run-8814/worker-07 POST /repos/acme/api/pulls 20114:22:14 run-8814/worker-31 POST /repos/acme/api/pulls 20114:22:19 run-8814/worker-07 POST /repos/acme/api/deployments 201...15:17:56 run-8814/worker-44 POST /repos/acme/api/pulls 20115:18:02 run-8814/worker-44 POST /repos/acme/api/pulls 401 <- token expiredNothing in the first block is false. Those revocations happened, and the orchestrator really is dead. The stop just had no bearing on the second block, which ends at 15:18 because that is when the tokens age out, not because anyone stopped anything. Do that arithmetic and the shape of the problem falls out. Your exposure is not the time from your decision to your action. It is the time from your decision to the last expiry you already handed out. You have fifty zombies and a green audit log.
Four things went wrong. None of them raised.
registry.children_of() returns the children the orchestrator told the registry about. A sub-agent that spawned its own helper, or that was mid-dispatch when the orchestrator died, never made that list. arXiv:2605.08460 catalogues this as one of four subagent-spawn gaps and names the relevant one "improper termination authority."
Revoking access_token may leave the refresh token alive, per the asymmetry above, and the 200 does not tell you which you got. A worker that refreshes on 401 reads your revocation as a transient error and mints a replacement. MojoAuth modelled this and found that revoking only the access token still permits more than 1,300 requests at a two-hour horizon.
Any resource server validating the JWT locally never learns the token was revoked. It checks the signature and the exp claim. Both are fine.
Work already in flight does not unwind. Chancery's author, who built a Model Context Protocol (MCP) proxy for this exact problem, put it plainly: "the gate can refuse the next call; it can't un-send an email."
How long the zombie window lasts after a mid-task revocation
Defaults, taken from vendor documentation rather than from a survey:
| System | Lifetime | Revocable mid-session? |
|---|---|---|
| AWS Security Token Service (STS) assumed-role session | 1 hour default, 12 hours max | No, and the next section covers what AWS does instead |
GITHUB_TOKEN, GitHub-hosted runner | 6 hours, the maximum job execution time | Bounded by job duration |
GITHUB_TOKEN, self-hosted runner | Job cap 5 days, refreshable only up to 24 hours | Bounded by the refresh limit |
| GitHub Actions OpenID Connect (OIDC) JWT | 5 minutes | No |
| Microsoft Entra access token, standard client | 60-90 minutes, randomised, 75 average | Not until expiry |
| Microsoft Entra with Continuous Access Evaluation | 24-28 hours | By signal, if both client and resource support it |
An hour is the common case. Every MojoAuth figure in this article comes from a single discrete-event model carrying the call-rate assumptions stated here, so read them as a shape rather than as measurements. MojoAuth built that model, sampling 366 revocation instants across a 40-minute task at one tool call every five seconds, and put mean remaining working time at 1,794 seconds, with as many as 719 further requests served after revocation. That is a simulation with a stated call rate, not production telemetry, and it deserves to be quoted with those assumptions attached. I could find no published production measurement of an agent zombie window anywhere. The shape of the quantity is familiar, though. I have measured its cousin on the device side and called it the Revocation Horizon: the elapsed time between deciding to change behaviour and the last actor actually running that change. That article is about model behaviour on a fleet rather than credentials on a call path, and it disambiguates itself from certificate revocation for good reason, but the governing number is the same in both places. It is the upper percentile, not the mean, and almost nobody measures it.
Continuous Access Evaluation looks like the fix and mostly is, with two conditions attached. It needs a CAE-capable client and a CAE-capable resource; without both, your default lifetime stays in the 60-to-90-minute band Microsoft documents as a randomised default. And a CAE session extends the token to between 24 and 28 hours, which moves the entire guarantee onto the signal channel. MojoAuth surveyed 18 public issuers and found one serving a Shared Signals transmitter configuration. The standard is real and the deployment is not, which makes CAE a plan rather than a control for most teams reading this today.
Where the token introspection cache reopens the hole you just closed
An obvious answer to an unrevocable JWT is to stop validating it locally. Call RFC 7662 token introspection instead and let the authorization server decide. That works. It puts a decision on the path, which is the right move.
Then someone measures the latency and adds a cache. The spec anticipates this in its own Security Considerations, and the language is unusually direct:
"The response MAY be cached by the protected resource to improve performance and reduce load on the introspection endpoint, but at the cost of liveness of the information used by the protected resource to make authorization decisions... the token may be revoked while the protected resource is relying on the value of the cached response to make authorization decisions. This creates a window during which a revoked token could be used at the protected resource."
A cache of duration C gives you the same exposure as a token whose lifetime is C. MojoAuth's model confirms the equivalence numerically: identical mean exposure, identical maximum exposure, identical cost in introspection calls. Caching an introspection response converts an on-path decision back into an off-path one. What you keep is the feeling of having fixed it.
The right way: revoke the authority and leave the token alone
AWS solved this in public, in documentation, years before anyone attached the word agent to it.
You cannot revoke an STS session. AWS says so. What the console's revoke-sessions button does instead is attach an inline deny policy to the Identity and Access Management (IAM) role:
{ "Version": "2012-10-17", "Statement": [ { "Effect": "Deny", "Action": ["*"], "Resource": ["*"], "Condition": { "DateLessThan": {"aws:TokenIssueTime": "2026-09-13T14:22:09Z"} } } ]}The credential stays valid. Its signature still verifies. A zombie holding it is still perfectly authenticated. What changed is the answer IAM gives when that credential is presented, and the only reason this works is that IAM is evaluated on every single request. Move the policy decision off the request path and the technique stops functioning entirely.
The obvious reply is that this is opaque tokens with extra steps. It is not, and the difference is worth being precise about. AWS keeps the credential self-contained and locally verifiable, and puts the revocation in the policy, keyed on a claim already inside the token. That is a third design: self-contained authentication, centralized authorization. A signature is an authentication fact, and it is unrevocable by construction, because it is a mathematical statement about something that already happened. A policy is an authorization decision, and it is revocable by definition, because it is a statement about now. The mistake is asking the token what the caller is allowed to do. Stop asking the token.
Note the timestamp. AWS sets the cutoff roughly 30 seconds into the future, and the documentation says why: "This future time choice takes into account the propagation delay of the policy." Even a synchronous global policy engine pads for propagation. Any design that treats revocation as instantaneous assumes something AWS declines to assume about its own infrastructure.
One limit before you copy it: aws:TokenIssueTime is only present for temporary credentials. Point this at a long-term IAM user access key and the condition never matches, so the deny never fires. The technique needs a credential that knows when it was issued.
The same decision, generalised off AWS and stripped to what a gateway needs:
# authority.py - the decision a gateway makes on every callimport timefrom typing import ProtocolPROPAGATION_PAD = 30 # seconds, matching the AWS cutoff conventionclass RevocationStore(Protocol): """Shared and durable. NOT a module dict, which is per-replica, and NOT graph state, which a checkpoint rewind would roll back.""" def get_cutoff(self, run_id: str) -> int | None: ... def set_cutoff(self, run_id: str, cutoff: int) -> None: ...def revoke_run(store: RevocationStore, run_id: str) -> int: """Deny every token minted for this run, including any issued during the propagation window. Monotonic: a retry or a second operator must never move the cutoff backwards and un-deny a token. A cutoff alone is not a stop. It lapses the moment the pad expires, so the issuer must ALSO stop minting for this run. AWS is explicit about the same gap: anyone who assumes the role more than about 30 seconds after a revoke "is not affected" by it.""" cutoff = int(time.time()) + PROPAGATION_PAD store.set_cutoff(run_id, max(cutoff, store.get_cutoff(run_id) or 0)) return cutoffdef authorize(store: RevocationStore, claims: dict, now: int) -> bool: run_id, iat, exp = claims.get("run_id"), claims.get("iat"), claims.get("exp") if run_id is None or iat is None or exp is None: return False # a missing claim is a denial, never a pass if exp <= now: return False cutoff = store.get_cutoff(run_id) return cutoff is None or iat >= cutoffNothing here is clever, and that is the point: the design content sits entirely in where these lines run, not in what they say. Three things in them are easy to get wrong, though, and each one silently re-authorizes a zombie.
The cutoff is not a revocation. It denies tokens minted before a moment in time, so thirty-one seconds later any issuer still willing to mint for that run produces a token that sails straight through. Revoking without also cutting off the issuer reproduces the exact refresh hole this article indicts stop.py for.
The cutoff record has to outlive the longest token you will ever issue. Evict it early and every surviving token is authorized again, which is the real reason revocation state is more expensive than it looks: you are not storing a flag, you are storing it for longer than your worst-case credential lifetime.
And it must not live in your agent's graph state. I called that failure a Self-Restoring Bound: a limit or grant whose remaining allowance is re-derived from checkpointed state on every entry, so a resume restores authority that was already spent. A revocation cutoff is the mirror image, a deny record rather than a grant, and it fails the same way. Write it into checkpointed state and a rewind un-revokes it.
Where the gateway sits: on-path and off-path calls, drawn
direction: down
op: "operator hits stop" {
style.fill: "#FFD93D"
style.stroke: "#C9A227"
style.font-color: "#2C2C2A"
}
as: "authorization server\n(revocation list,\nintrospection endpoint)" {
style.fill: "#95A5A6"
style.stroke: "#5F6E6F"
style.font-color: "#2C2C2A"
}
dispatched: "already dispatched" {
style.fill: "#F5F5F3"
style.stroke: "#955D37"
style.font-color: "#2C2C2A"
w1: "sub-agent\n(holds signed token)" {
style.fill: "#FFA07A"
style.stroke: "#D9713F"
style.font-color: "#2C2C2A"
}
w2: "sub-agent\n(holds signed token)" {
style.fill: "#FFA07A"
style.stroke: "#D9713F"
style.font-color: "#2C2C2A"
}
}
gw: "gateway\nevaluates authority\nper call" {
style.fill: "#7B68EE"
style.stroke: "#5546C4"
style.font-color: "#FFFFFF"
}
api: "production API\n(validates the signature\nlocally, asks no one)" {
style.fill: "#4A90E2"
style.stroke: "#2C6FB0"
style.font-color: "#FFFFFF"
}
op -> as: "revoke"
as -> dispatched: "advisory: nothing\ninside here asks" {
style.stroke: "#E74C3C"
style.stroke-dash: 4
style.font-color: "#B3291B"
}
op -> gw: "set cutoff" {style.stroke-width: 3}
dispatched.w1 -> gw: "on-path call"
gw -> api: "allowed or denied"
dispatched.w2 -> api: "off-path call\n(raw SDK, sibling agent,\nlocal MCP server)" {
style.stroke: "#E74C3C"
style.stroke-width: 3
}
That dashed red arrow is the one that matters. Revocation reaches the authorization server and stops there, because nothing inside the dispatched boundary is obliged to ask it anything. The solid red arrow shows why coverage is an architecture problem rather than a configuration problem: w2 is not misconfigured, it simply never had a gateway in its path.
Two questions, not four revocation controls
The controls in this space get listed as a menu of four, which is wrong, because they answer two independent questions. Where is the decision evaluated, and what is the bound denominated in? Cross those and you get the real map:
| Denominated in time | Denominated in operations | |
|---|---|---|
| Off-path (nothing asks at call time) | Short TTL, revocation lists | Nothing useful lives here |
| On-path, decision fetched | Introspection, IAM policy evaluation | Operation budget at a gateway |
| On-path, decision pre-delivered | Heartbeat-bound credential | Budget stapled to the credential |
Identity engineers have seen this map before under different names. Revocation lists are certificate revocation lists. Introspection is the Online Certificate Status Protocol (OCSP), a call to ask whether the thing in your hand is still good. Heartbeat-bound credentials are OCSP stapling, where the answer is pre-delivered alongside the credential so the verifier asks nobody. None of this is new cryptographic ground. It is the same three answers, arriving in agent infrastructure about fifteen years late.
Two things fall out of the map that the four-item list hides. Nothing off-path stops anything, so a short TTL decides only how long a zombie gets to live. And the entire right-hand column is nearly empty in practice, which is the interesting part.
Time-based bounds leak in proportion to how fast your agents run. arXiv:2603.09875 makes this explicit, modelling exposure under a TTL lease as O(v·TTL) for agent velocity v. Same window, faster agents, proportionally more unauthorized calls. At a 60-second window and 100 operations per tick the model puts unauthorized calls in the thousands; at serverless-scale velocity, in the hundreds of thousands.
A bound expressed in operations has no such property. Grant a capability token 50 uses and it is 50 uses whether the agent takes an hour or four seconds:
# budget.py - a bound that does not care how fast the agent runsfrom typing import Protocolclass BudgetStore(Protocol): """Durable and shared. NOT graph state, or a checkpoint rewind restores the budget you already spent.""" def try_spend(self, run_id: str, capability: str) -> bool: """Atomically decrement if positive, returning whether it did. One round trip, not two. Redis: DECR and compare, or a Lua script. Postgres: UPDATE ... SET n = n - 1 WHERE n > 0 RETURNING n."""The atomicity is the whole control, so it is worth saying why the obvious version is wrong. Read the budget, check it is positive, then decrement, and fifty concurrent workers all read a positive value before any of them writes. A budget of 50 admits 50 plus your concurrency. In the opening scenario that is exactly fifty extra deployments, which is the failure an operation bound exists to prevent.
Choosing that number is a different conversation from choosing a TTL, and a more honest one. A time-to-live asks how long you are willing to be wrong, which nobody can answer without first knowing how fast the agent will move during the window, and agent speed is exactly the variable that changes every time you upgrade a model or raise a concurrency limit. An operation budget asks how many times this capability may fire before a human looks again, and a reviewer can answer that during design review because it is denominated in the same units as the damage: fifty pull requests, ten deployments, one payment. A budget also covers the case TTLs handle worst, where an agent sits idle for fifty minutes and then issues four hundred calls in the last ten.
That paper reports a 120-fold reduction in unauthorized operations against a TTL lease, and 184-fold when revocation is anomaly-triggered. Simulated figures, from an unreviewed preprint, with a reference implementation its own authors describe as academic rather than production software. Treat the shape of the result as the interesting part rather than the multiplier.
And be clear about what velocity-independence costs. An operation bound buys its guarantee with a linearizable counter that every call has to touch, which is a new consistency requirement and a new availability dependency sitting on the request path. You have not escaped the on-path problem. You have chosen to pay it in a different currency, and in exchange you get a bound that does not quietly widen the next time someone raises a concurrency limit.
Heartbeat-bound credentials are the least familiar cell on the map, so here is the mechanism rather than the marketing. A controller signs a short-lived liveness assertion for each running agent. The agent staples that assertion to every request, the way a server staples an OCSP response. The resource server checks it offline against the binding already inside the credential, and a missed heartbeat fails the credential closed with nobody calling anyone. The round trip does not disappear, it moves: the credential holder fetches heartbeats on a schedule, off the request path, where a slow authorization server costs you a stalled agent instead of a stalled call.
That last property is the real argument for it, and the reason to watch this row despite thin evidence. The paper reporting it measures a zombie window under 40 seconds against an assumed OAuth baseline of 15 to 60 minutes, at 0.71% end-to-end overhead. One single-author preprint, benchmarked against its own assumed baseline. Worth reading, not worth quoting as a measurement, and not yet worth building on.
The objections worth taking seriously
"Short TTLs solve this"
They bound it, and the cost curve turns ugly faster than people expect. MojoAuth's model puts a 15-second lifetime at 160 refreshes per 40-minute task and roughly a 15% task-failure risk from refresh failures alone. Below about 30 seconds the refresh round trip starts to dominate the call itself. Worse, the standard mitigations for the resulting thundering herd, single-flight locks and jittered refresh, are shared coordinated state, which is the thing you were trying to avoid building. MojoAuth's framing is the one to keep: in a chain with no revocation propagation, the access-token lifetime is your revocation service level agreement. This is also the argument I made from the other direction in why temporary keys are not enough - a short-lived credential with broad permissions is still a broad credential, just briefly.
"A gateway solves this"
It does stop the next call, and the latency is affordable. One pre-action authorization implementation measures a 53 millisecond median decision across a thousand cases, which is noise beside a model turn. Treat that number carefully, though. A median from a thousand lab cases says nothing about the statistic that actually decides adoption, which is p99 under production concurrency on a dependency every tool call now blocks on, and I have not seen that published for any of these designs. Coverage is the harder part still. An agent that instantiates a cloud SDK client with a raw key, calls a sibling agent directly, or spawns a local stdio MCP server has a gateway that was never in the path. Closing that needs deny-by-default egress and DNS control, not a gateway setting. A tool execution firewall has the same boundary condition: it governs what passes through it, and nothing about what routes around it. And a gate still cannot un-send.
"Our identity provider handles this"
Vendors in this space will tell you that revoking an agent's identity invalidates every chain that depended on it. Inside an RFC 8693 token-exchange architecture where a gateway introspects on each hop, that is true, and it is true for exactly the reason this article argues: something is on the path. The guarantee belongs to the deployment, not to the identity provider.
"We just disable the OAuth client"
This is the reply I would expect first from anyone who has actually run an incident, and it is the right instinct: suspend the service principal, rotate the client secret, and no new tokens get minted for anybody. It works, and it is strictly better than calling revoke in a loop. It also does nothing to the tokens already issued, which keep verifying until they expire, because disabling the client is a change at the issuer and the issuer is not on the path of the call. It stops the bleeding and leaves the zombies standing.
"We kill the process"
Killing a process does not recall in-flight calls, does not invalidate credentials the agent already copied into a worker's environment, and does not stop peer agents that were coordinating with it. A stop that lets current actions finish is a pause.
What I could not verify
Stating these is cheaper than having a reader find them.
The missing incident, flagged at the top, is the big one: no public case I can find has an agent demonstrably shipping code on a revoked credential. Every zombie-window figure in this article is simulated or closed-form too, sourced from either a vendor blog or an unreviewed arXiv preprint. My load-bearing citations are deliberately the boring ones: RFC 7009, RFC 7662, and vendor documentation from AWS, GitHub, and Microsoft.
I also could not verify a claim common in this discussion, that production agent systems routinely run delegation chains three or more links deep. Vendor guidance in this space talks about capping chain depth at three to five hops at the token-exchange endpoint, but a cap is what implementers permit, not a measurement of what production runs, and I found no published figure for the latter. Do not repeat the depth number as though someone counted.
Well supported, separately and by a different source, is the monotonicity rule. WorkOS states it cleanly: each hop should carry equal or lesser permissions than the previous one, and no agent should be able to spawn a more powerful agent than itself.
A checklist for your own agent stop procedure
Work through these against the runbook you actually have.
- Find the decision point. For each credential your agents hold, name the component that evaluates authority when that credential is presented. If the answer is "the resource server checks the signature," you have no decision point and revocation will not work.
- Revoke the refresh token, not only the access token. RFC 7009 makes access-token revocation a SHOULD and refresh-token revocation a MUST. Most runbooks call the weaker one.
- Check your introspection cache duration. A cache of duration C hands back the exposure of a token whose lifetime is C. If the cache is as long as the token, you bought nothing.
- Pad the cutoff. Set your revocation boundary slightly in the future, as AWS does at roughly 30 seconds, so tokens minted during propagation are denied too.
- Keep the cutoff out of checkpointed state. A rewind restores whatever the checkpoint remembered, including an authority you revoked. Store it against the principal.
- Enumerate the off-path calls, then close them at the network. Raw SDK clients, direct agent-to-agent calls, local stdio MCP servers, shelled-out binaries. None of these is a misconfiguration you can fix in a settings file, so fix them one layer down: deny-by-default egress, DNS control, and no credential in a worker's environment that your gateway did not mint.
- Audit who writes the policy your gateway evaluates. A decision on the path is worth nothing if the workload authors the condition. That is how
if: always()survives a cancel, and an agent that can edit its own policy file has the same exemption. - Suspend the client, and know what that does not do. Rotating the secret and disabling the service principal stops new tokens immediately. Tokens already issued keep working. Do it first, then keep working the list.
- Add a bound that is not time-based. An operation budget per capability holds regardless of how fast the agent runs, at the price of a linearizable counter on the call path.
- Test the stop rather than assuming it. Dispatch children, revoke, then count the actions that land afterwards. That count is your real revocation service level agreement.
Point 10 is the one worth doing this week. The kill switch I built passes its own audit by confirming a slot is filled, which tests configuration rather than stopping. A stop you have never measured is a stop you are assuming.
Your tokens are not the thing you can take back. The authority is, and only where something checks it. Everything else is a request, and requests are what if: always() is for.
References
- Lodderstedt, T., Dronia, S., & Scurtescu, M. (2013). RFC 7009: OAuth 2.0 Token Revocation. IETF. https://www.rfc-editor.org/rfc/rfc7009
- Richer, J. (Ed.) (2015). RFC 7662: OAuth 2.0 Token Introspection. IETF. https://www.rfc-editor.org/rfc/rfc7662
- Deochake, S. (2026). Heartbeat-Bound Hierarchical Credentials: Cryptographic Revocation for AI Agent Swarms. arXiv:2605.20704. https://arxiv.org/abs/2605.20704
- Cai, Z., Zhang, Y., & Hei, X. (2026). When Child Inherits: Modeling and Exploiting Subagent Spawn in Multi-Agent Networks. arXiv:2605.08460. https://arxiv.org/abs/2605.08460
- Parakhin, V. (2026). The Bureaucracy of Speed: Structural Equivalence Between Memory Consistency Models and Multi-Agent Authorization Revocation. arXiv:2603.09875. https://arxiv.org/abs/2603.09875
- Uchibeke, U. (2026). Before the Tool Call: Deterministic Pre-Action Authorization for Autonomous AI Agents. arXiv:2603.20953. https://arxiv.org/abs/2603.20953
- Jones, M., Nadalin, A., Campbell, B., Bradley, J., & Mortimore, C. (2020). RFC 8693: OAuth 2.0 Token Exchange. IETF. https://www.rfc-editor.org/rfc/rfc8693
- Amazon Web Services. Revoke IAM role temporary security credentials. AWS IAM User Guide. https://docs.aws.amazon.com/IAM/latest/UserGuide/id_roles_use_revoke-sessions.html
- GitHub. Workflow cancellation reference. GitHub Docs. https://docs.github.com/en/actions/reference/workflows-and-actions/workflow-cancellation
- GitHub. (2023, September 21). GitHub Actions - Force cancel workflows. GitHub Changelog. https://github.blog/changelog/2023-09-21-github-actions-force-cancel-workflows/
- Microsoft. Continuous access evaluation in Microsoft Entra. Microsoft Learn. https://learn.microsoft.com/en-us/entra/identity/conditional-access/concept-continuous-access-evaluation
- Microsoft. Configurable token lifetimes in the Microsoft identity platform. Microsoft Learn. https://learn.microsoft.com/en-us/entra/identity-platform/configurable-token-lifetimes
- MojoAuth. (2026, August 13). Revoking an Agent's Access Mid-Task: Token Lifetime Design for Agentic Systems. https://mojoauth.com/blog/revoking-an-agents-access-mid-task-token-lifetime-design
- Gupta, A. (2026, July 13). What I learned trying to revoke an AI agent mid-task. DEV Community. https://dev.to/anee769/what-i-learned-trying-to-revoke-an-ai-agent-mid-task-m80
- WorkOS. (2026, April 27). AI agents and the multi-hop delegation problem. https://workos.com/blog/oauth-multi-hop-delegation-ai-agents
- Infosecurity Magazine. (2026, January 8). New Zero-Click Attack Lets ChatGPT User Steal Data. https://www.infosecurity-magazine.com/news/new-zeroclick-attack-chatgpt/
Related Articles
- Build a Kill Switch for a LangGraph Agent
- From Unknown Codebase to Architecture Doc, Automated - Building the LangGraph Pipeline
- Capability Tokens: Fine-Grained Authorization for Non-Deterministic Agents
- Securing MCP Servers: Context Injection & Data Exfiltration



