AI & agents

Revoking an agent's access, instantly

Access tokens are stateless, so revoking one is usually a polite request that takes effect when it expires. Here's the approach that doesn't work, and the one we shipped.

SchemaStack team21 Aug 2026Verified working · 21 Aug 2026Docs

An AI agent is connected to your workspace and you want it out. Not eventually — now.

This is harder than it sounds, and the reason is a design choice almost every OAuth implementation makes for good reasons. Access tokens are stateless: signed JWTs, verified by checking a signature and an expiry, never looked up in a database. That's what makes them fast. It's also what makes "revoke this token" a sentence with no obvious implementation, because there's nothing to delete.

So what most systems do — what we did — is revoke the refresh token. The application can't get a new access token, and the one it holds dies of old age. Access tokens last an hour here, so "revoked" quietly meant "loses access within the hour."

For an application an administrator added, there was an escape hatch: disable the client, and authentication starts refusing its tokens on the next request. But a self-registered connector belongs to no workspace. The workspace whose data it's reading can't disable it. So the one case that most needed an immediate stop was the one case that didn't have one.

The fix that cannot work

The tempting answer: we already record when a refresh token was revoked. Take the most recent revocation for this client and user, and refuse any access token issued before it. No new table, no new writes.

It doesn't work, and the reason is worth sitting with.

Refresh tokens rotate. Every time an application exchanges one, the old token is marked revoked and a new pair is issued — in the same transaction:

// Rotate: revoke old, issue new
token.setRevokedAt(OffsetDateTime.now());
return refreshTokenRepository.persist(token)
    .chain(() -> generateTokenPair(/* … */));

That revokedAt and the new access token's iat are the same instant. A watermark derived from refresh-token revocations would therefore invalidate the token it had just minted, on the first request, forever. Rotation and revocation are indistinguishable from the outside — a normal refresh looks exactly like someone hitting the kill switch.

Which is a good reminder that "reuse the data you already have" is a heuristic, not a rule.

What we shipped

A watermark table. One row per revocation, append-only:

client_idwhich application
workspace_idin which workspace
user_emailwhose grant — or NULL, meaning everyone's
revoked_beforethe moment

Authentication reads the latest matching row and refuses any token whose iat precedes it.

timerevoked hereone row, append-onlyiatrefusediatrefusediatrefusediathonouredsame second → refused
A revocation records a moment. Tokens issued before it are refused from then on — including the one the agent is already holding.

Append-only matters: a revocation is a fact about a moment, not a value to keep current. Nothing to update, no row to contend for, and the reader takes the most recent — so two administrators revoking at once is not a race, it's two facts.

Scoping matters too. A self-registered client can hold grants in several workspaces at once. An administrator of one of them may cut off their own workspace's access; reaching into the others isn't theirs to do. So revocation is scoped to the workspace that asked.

Erring in the right direction

A JWT's iat is whole seconds. Our revoked_before has sub-second precision. When a token is issued in the same second as a revocation, which came first is genuinely unknowable.

We refuse it.

return issuedAtSeconds <= watermark.toEpochSecond();

The mistake in one direction costs a click: sign in again. The mistake in the other direction hands an agent up to an hour of access after someone decided to stop it. That's not a close call, and the comparison is <= rather than < on purpose.

Enforced twice, deliberately

The rule lives in two places. The metadata service (which serves MCP) has GrantRevocation.isRevoked. The Workspace API — a separate Spring Boot service that shares no code with it by design — restates it in OAuth2TokenValidationService.isRevoked.

Duplicated logic is a liability, so both are unit-tested with the same cases: no watermark, before, same second, after, and a token with no iat at all. Change one and forget the other, and a test fails — rather than a revoked token continuing to work on one surface while being refused on the other, which is precisely the sort of bug nobody finds for months.

There's a third enforcement point that costs nothing extra: reusing a stolen refresh token. A replayed token was already treated as theft and revoked everything; now it writes a watermark too, so the access tokens already issued die with it instead of outliving the theft by an hour.

The Workspace API's watermark read is deliberately not cached, unlike its client-enabled check. The entire point is that it takes effect now; a cache would give it a lifetime of its own. And if that read fails, it fails closed — a token whose standing can't be established isn't honoured.

What it doesn't do (yet)

  • A revocation is a moment, not a rule. It stops what exists; it doesn't stop the same person consenting again a minute later. Removing an application's ability to ask is a separate action (disable it), and only exists for administrator-registered clients.
  • No revocation of one specific token. The granularity is client, or client-and-user, within a workspace.
  • No propagation to anyone else. RFC 8935 security event tokens — telling a client "your grant is gone" rather than letting it discover so — aren't implemented.
  • Spent watermarks are cleaned up on a schedule, not immediately; a row lives long after the last token it could refuse has expired.
  • Nothing here applies to mcp_ API keys, which are looked up on every request and so are revoked by deletion, instantly, and always were.

Where to click: Workspace → OAuth2 lists every application with an active grant and who approved it. Revoke the application, or one person's grant. Either way it bites on the next request.

Verified 21 Aug 2026: 20 tests across both codebases — the rule itself in the metadata service (4) and restated in workspace-api (5), plus the connector integration suite (11) which revokes a live grant and proves the same unexpired token stops working.