Laxmikant Sharma

[writing]

How Long Does a Revoked Agent Keep Working?

I tried to write down the formula for revocation latency and it kept being wrong in instructive ways. The corrections come from validator source code: a five-minute grace on expired tokens in .NET, streams that outlive their token by design, and a 12-hour JWKS refresh on the one lever that actually works. With runnable experiments.

[agents][security][authorization][agentic-web]

Part 2 of three on agent accountability. Part 1 asked whether the record exists. This one asks whether you can take the authority back.

A comment on Part 1 set this piece up better than my outline did: you can answer all four of Part 1’s questions perfectly at T₀, have the authority revoked, and watch the tool call happen at Tₙ anyway. Perfect observability would prove exactly why a stale authorization executed without ever preventing it. So the real question is: hit revoke at T₀, then at what Tₙ does a request actually start failing?

I tried to write that down as a formula, and the article is the design review it turned into: sketch the obvious model, let the room take swings at it. Every swing comes from validator source or a runnable test, not a spec.

The whiteboard answer

The bearer token in this story is a JWT, and the resource server checks four things: signature against the issuer’s published keys, iss, aud, exp. All four local. No call to the authorization server on the request path, which is most of the reason JWTs won.

One of the reviewers stopped me here: their resource server doesn’t validate anything, it asks the authorization server. True, in two shapes. Same-party, a Slack API node checking a Slack token: validation and revocation are one company’s database, and revocation there mostly just works. Opaque tokens: the resource server calls home through RFC 7662 introspection, so somebody already built the signal channel; later in this post we’ll check who actually offers one (six of the twenty I reviewed).

This article is about the third shape, the one MCP’s authorization model standardizes: your resource server accepting a JWT minted by somebody else’s authorization server, separate roles with discovery between them. Entra is the concrete case: no introspection endpoint in any discovery variant I checked, and Microsoft’s own validation guidance for web APIs is local, signature and issuer against the discovery document. For that shape the four checks are the entire validation. And introspecting per request doesn’t escape the formula, it sets T_signal to your introspection cache TTL, which is where this article ends up anyway, just chosen deliberately.

Now revoke that token at the authorization server. Which of the four checks changes its answer?

After a revocation, the authorization server knows and the resource server has no way to. Every local check still passes.

RFC 7009 acknowledges this directly. Section 2.1: the invalidation “takes place immediately.” Section 3, the implementation note, concedes what that means for a self-contained token: immediate effect at a resource server needs “some (currently non-standardized) backend interaction,” or you fall back to short-lived tokens. That note is from August 2013. The standard that would fill it arrived in 2025, and below I measure how deployed it is.

First pass at the whiteboard:

The bound, first attempt: R equals the smaller of the token’s remaining life and the time for any signal to arrive.

With no signal channel, R = T_remaining, worst case the full TTL. So the next thing on the board is what the TTL actually is, from the vendors’ own docs:

Access token lifetimes from vendor documentation: Atlassian 60 minutes, Entra 60 to 90, GitHub Apps 8 hours, Slack 12 hours with rotation, and no expiry at all for default Slack and GitHub OAuth apps.

The two no-expiry rows aren’t carelessness; those defaults were negotiated with a decade of integration developers who wanted CI jobs that didn’t page anyone, long before anything held those tokens at one call per second.

That’s the first pass. It needs three corrections, all of them from implementations rather than specs.

Then someone asks which library does the checking

The first objection is quiet, and it comes from whoever operates the .NET services. As implemented, the expiry check isn’t now < exp. Every major validator tests now < exp + L, where L is a clock-skew allowance the library picked for you: zero by default in the Node, Python and Go stacks, sixty seconds in the Java ones, five minutes in .NET. I read the source for the majors because docs describe intent and defaults describe deployments.

Default clock-skew leeway in seven JWT libraries, read from source. Node, Python and Go ship zero. Java ships 60 seconds. .NET ships 300.

The last row is the one to check your own stack against: an ASP.NET resource server, at defaults, honors a token for five minutes after its own exp claim says it died. Deliberate, documented in a code comment, invisible to operators. So the model on the board picks up a term:

Correction one: R equals the smaller of the token’s remaining life plus the validator’s leeway, and the signal time.

Which means your revocation latency depends on what language the resource server is written in. Same token, same issuer, same revoke call: the Node service stops accepting at exp, the Java service a minute later, the .NET service five minutes later. Nobody chose that. It fell out of seven independent defaults.

And the streams person hasn’t said anything yet

The harder correction is streams. The formula assumes the token is checked per request; it’s checked per admission, and those are different things for anything long-lived. Rather than argue the point, I wrote a 60-line resource server to show it: the server validates an HS256 token properly, signature and expiry, then serves a server-sent-events stream. Token minted with five seconds to live, one event per second for fifteen.

Experiment 1: the admission check passes at t=0, the token dies at five seconds, and ten more events arrive anyway.

The server isn’t buggy. It checked everything OAuth asks for, at the moment OAuth asks for it. Expiry is a property of admission, not of the connection.

Now map it onto agents. MCP requires the token on “every HTTP request,” and its transport delivers server notifications as an SSE stream hanging off a single request. One request, one admission, then an open pipe for as long as the subscription lives.

Production systems that have thought hard about this hit the same wall: Microsoft’s CAE docs note that in co-authoring sessions a policy change “might not revoke their access to the document immediately,” and access actually ends when the document closes.

Both corrections turn out to be the same correction. The formula is about admissions, not tokens:

Final form: R equals the smaller of the signal time and A, where A is the first admission the credential fails. For a stream, A is the connection lifetime.

Two useful things fall out of the admission framing. First, it says what fixes streams: you can’t re-check authority mid-connection, so make connections end. A maximum stream lifetime plus draining on revocation events turns A from “whenever the network hiccups” into a number you chose, and the very next reconnect fails the admission and self-corrects.

Second, it reframes refresh rotation: every expiry forces the client back to the authorization server, the one party that knows the grant is dead. The TTL was never a security timer on the token. It’s the interval between mandatory visits to the revocation list.

Put every clock so far on one axis:

One revocation at minute two of a ten-minute token. The Node server stops at ten minutes, Java at eleven, .NET at fifteen, the stream when its socket dies, and a push channel after the chart ends.

So who can actually be told?

Back to T_signal. Two standardized mechanisms can make T_signal finite: the resource server asks (RFC 7662 introspection), or the issuer pushes (OpenID Shared Signals and CAEP, Final since September 2025). So I recently pulled the discovery documents for twenty public providers, recorded what each advertises, and probed the ten largest for a Shared Signals transmitter configuration. The run is date-stamped in the printout, and the script is linked at the end.

Thirteen of twenty providers advertise revocation, six advertise introspection, six advertise neither, and zero of ten serve a Shared Signals transmitter configuration.

Two honesty notes on the columns. “No” means not advertised, not absent: Google’s tokeninfo, Slack’s auth.test, Twitch’s /oauth2/validate, Discord’s oauth2/@me all exist, just not in the discovery document, which matters once the client is built to a spec rather than by a person, because MCP makes discovery mandatory and cites RFC 7009 zero times in 2.4 MB of documentation.

And introspection usually sits behind a gateway cache that holds the “active” verdict for seconds to minutes, so ask-every-time is really ask-on-a-timer. Asking is an admission like any other.

Microsoft’s CAE is the one deployed push channel that publishes its own numbers, which is why I keep quoting it. Its docs open by naming “the lag between when conditions change for a user, and when policy changes are enforced,” and what CAE adds is that “a resource provider can reject a token when it isn’t expired”: critical events, including an admin revoking all refresh tokens, propagate with “latency of up to 15 minutes,” paid for by token lifetime rising to as much as 28 hours.

And in the same doc’s limitations: Conditional Access policy and group membership changes “could take up to one day to be effective.” Identity revocation, fifteen minutes; capability revocation, a day.

Those two numbers also expose a crossover the formula makes obvious: a channel only pays when its propagation beats the token’s remaining life. Against a 28-hour token, fifteen minutes is a 112x win. Against a ten-minute token it would never fire first. Channel and TTL are one latency budget split two ways.

The lever nobody calls revocation

Here’s the correction I didn’t have until I read JWKS cache code. T_signal is never actually infinite, because one revocation signal is honored wherever the issuer’s keys are fetched rather than pinned: the key set itself. Rotate the signing key out of the JWKS and every token signed with it fails everywhere, as soon as each server’s cached copy expires. The channel has always been there. Nobody frames it as revocation, and its latency has a trap.

The kid-miss trap: a new key triggers an immediate refetch, a rotated-out key is still cached, so the revoked token is accepted until the cache timer expires. On .NET, that timer defaults to 12 hours.

Rotation is blunt, though: it invalidates every outstanding token from that issuer, for every client and every user at once. Keycloak ships a narrower version, a push not-before policy that invalidates everything issued before a timestamp, and its delivery caveat is the same T_signal problem again: the push reaches only the clients that registered an Admin URL. And the cache numbers in the figure aren’t one library’s quirk: Microsoft’s own developer guidance calls checking for key updates “every 24 hours” a reasonable frequency.

Worth saying why MCP can’t just add a better signal. The transport’s message model is one sentence: servers send responses and notifications, “no other message direction exists,” and the only notification that could carry “stop” is fenced to subscription teardown with a MUST NOT. Sessions are gone, and the security guidance says never tie authorization to the session id anyway. A2A, the other agent protocol with a task lifecycle, is at least explicit about the limits: tasks/cancel requests cancellation, and “success is not guaranteed.”

The same release ships ttlMs, cacheScope and a listChanged invalidation signal for the tool list. So the tool list has a real-time invalidation path and the grant has none.

Or stop letting the token authorize

Everything so far shrinks a term someone else set. Shorter tokens shrink T_remaining, and both Microsoft (short lifetimes “degrade user experiences and reliability without eliminating risks”) and the OpenID Foundation (wall-clock expiry is “ill-suited for high-velocity agents”; bound by execution count instead) argue that’s the wrong lever for agents, for different reasons. Channels shrink T_signal and mostly don’t exist, as measured above.

The third option removes T_remaining from the formula: stop treating token validation as the authorization decision.

Move the check: the token authenticates, a policy decision point authorizes per tool call, and R becomes your decision-cache TTL. Measured floor: 42 microseconds per decision over keep-alive HTTP.

One boundary this option doesn’t move, and a reviewer drew it crisply: the per-call check pays off when the relevant change is observable to the decision point. Whether the conditions it evaluates sufficiently represent present reality is a harder question, and this piece deliberately stops at the credential. The measured version of that boundary is already above: identity events reach Entra’s enforcement in minutes, policy and group changes in up to a day.

The reflex objection is cost, which is why experiment 2 exists. The localhost floor is 42µs per decision; even a hundred times that, with a real network and real policy evaluation, is milliseconds against a tool call about to spend seconds doing consequential work. Skipping the check to save microseconds on a call that’s about to spend seconds is a bad trade.

And the spec for exactly this is being written now. AuthZEN’s Authorization API went Final in January 2026, and COAZ-MCP, a Draft 1 from February, maps every MCP method onto an AuthZEN evaluation, so an MCP gateway becomes the enforcement point. Look at its default mapping for a tool call, because the two identity lines differ by one character and the character is doing a lot of work.

The COAZ-MCP tools/call mapping beside Part 1’s execute_tool span. subject.id is required plain selection; the agent is optional selection inside an optional object.

$token.sub is plain selection, and plain selection on a missing key is an evaluation error: the human is required. $token.?client_id is CEL optional selection inside context, which the base spec makes optional: the agent is optional inside an optional. Part 1 found “on whose authority” had no field in the observability layer. In the authorization layer being drafted right now it has a required one, and it’s “which agent” that became soft.

The binding is honest about its trade-offs in a way specs usually aren’t, down to naming the confused deputy: every attribute a server-declared mapping produces is “untrusted input to the PDP.”

What’s still thin is the decision itself. {"decision": true} is a complete, conformant record of an authorization, and whether that’s enough to reconstruct anything later is Part 1’s question all over again, one layer down.

The layer with no R

Revoke everything above and the model still holds what it read at T₀. The vendors say so themselves: OpenAI’s deletion story requires deleting “every source where it appears” and re-enabling memory “may create new memories from chats that remain in your chat history”; Anthropic’s memory entries from a deleted conversation “won’t be removed”; Google’s connected-app deletions “may not be affected until days later.”

The research backs the vendors up: across 398 public unlearned models, most retain the target knowledge above baseline, and optimizing against the deletion audit teaches the model to hide it, “producing lower audit scores but greater post-attack recovery.” There’s no R here because there’s no operation whose completion you could time.

Five numbers to leave the room with

If parts of this made you want to argue, these are the numbers to bring. Each is an afternoon at most, and each replaces a belief with a measurement.

Five checks: token lifetime per provider, your validator’s leeway, your JWKS refresh, the stream test, and one end-to-end measured R. Plus the settings I’d argue for.

Still open on the whiteboard

Three questions survived the whole discussion, and each is live right now in a public thread with fewer people in it than it deserves.

The three open questions: where the delegation chain lives, whether a presented scope is checked or merely claimed, and what identifier joins the decision to the span to the evidence.

The small claim held up through every correction, though. Revocation latency is an engineering quantity with named terms, every term has a default someone else chose, and the defaults were chosen for a world where the principal was a person on a laptop, not a process making a call per second. The numbers here took an afternoon to collect, and the scripts are right here so you can collect yours: the discovery probe and the two experiments. Python 3, standard library only, no credentials, read-only apart from your own localhost.

Part 3 is about the rules. Since 2018, one industry’s rulebook has required a kill switch and, in the same article, knowing which algorithm is responsible for every order: a kill switch you can’t aim isn’t one. Part 3 reads it.