dmitriiev.dev

You revoked their access. Now go use it.

· 7 min read

You revoked their access. Now go use it.

Provisioning gets tested to death. Every hunter on a target pokes at how you get access: can a low-priv user reach the admin endpoint, can you read someone else’s object, does the invite flow leak a token. The grant side is crowded. What almost nobody checks is the other direction: after access is taken away, does it actually drop?

That’s the shift that pays. Not “can I get in,” which a hundred people already tried, but “they kicked me out, am I really out.” Revoke a teammate, delete an API key, rotate a password, downgrade a plan, log out. Each of those is a promise the product makes, and the promise lives in two different places that don’t always agree.

Two planes, one lag

When you revoke something, a control plane writes it down: the membership row flips to removed, the key is marked deleted, the role loses a permission. That part almost always works. You can watch it work in the UI.

The data plane is whatever was already holding access at that moment. A connection that’s already open. A JWT minted five minutes ago that’s valid until it expires. A session cookie sitting in a pool. A read replica that hasn’t caught the write yet. An edge cache serving a response from before the change. None of those asked the control plane for permission again, because they already had it.

Put a concrete shape on it. Say the backend verifies the JWT once on the first hit, caches the session in Redis for fifteen minutes, and never rechecks the ban flag in the database for the life of that cache entry. Ban the user and they keep going until the entry expires. There’s your fifteen-minute window, and you didn’t need anything exotic to pry it open, just a normal design choice that traded a database read for latency and quietly turned revoke into a delayed action. Most of these bugs look exactly like that: not a missing check, a cached one.

The bug is the gap between the two. The control plane says revoked. The data plane never got the memo, or got it late, and keeps honoring the old credential. For a window, sometimes a permanent one, the access you supposedly took away still works.

The surfaces that make this promise

Same drill as anything else: don’t test “the product,” list the specific places it promises to cut access off, and test each one like its own target. Once you start counting they’re everywhere:

Each one is a different code path written by a different person who was thinking about the happy path. They built the screen that shows you’ve been removed. Whether the removal reaches every place that was already trusting you is a separate question nobody owned.

Membership revocation is a good one to start on, because it usually involves a second party and that’s exactly the setup the crowd skips.

How to actually test it

The whole technique is: capture a live credential before the revoke, perform the revoke through the supported flow, then reuse the credential you captured. If it still works, the promise is broken.

Concretely, with two accounts. Account A owns a resource and invites B. As B, get yourself something durable that represents your access: mint an API key, open a long-lived connection, grab the bearer token your client is using, note your session cookie. Now, as A, remove B through the normal “remove member” button. The UI confirms B is gone.

Then go back and replay B’s old credential against the API directly. Not through B’s logged-out browser, which will just bounce you at the front door. Against the endpoint, with the token or key you captured. The question is whether the backend still answers.

You’re looking for the differential. A freshly minted request from B now gets a clean 403. The credential captured before the revoke still gets 200. Same user, same endpoint, two answers, and the only thing that changed is when the credential was issued relative to the revoke. That split is the bug, and it’s also the proof.

The oracle: time, not vibes

The risk here is the same as with any “it kind of still works” finding: you talk yourself into a bug that isn’t there. Maybe you fat-fingered the revoke. Maybe you’re hitting a cache that’ll clear in a second and the report dies in triage.

So you make it about time, not about a single lucky request. Revoke, write down the timestamp, then hammer the old credential on a loop and watch when it stops. Three shapes come back, each meaning something different:

A timestamp before and after, plus the request count, turns “seemed like it still worked” into a thing a triager can’t wave off. Tie the still-living access to a concrete action only the revoked party should be able to do, read the private object, post the message, charge the card, so the impact is a sentence, not a status code.

Why this stays fresh

Two reasons the crowd structurally leaves this alone, and both are why it’s worth your time.

It’s stateful. The bug only exists across a sequence: hold a credential, change something else, reuse the credential. Scanners are one-shot. They send a request and read the response; they don’t have a “before” and an “after.” A tool that fires the revoke and a tool that replays the old token are the same tool here, run in order, holding state between them, and that’s not what automated tooling does.

And it’s setup-gated. The good cases need two accounts, or a paid plan to even reach the gated feature, or a long-lived connection you had the patience to keep open. Effort filters the field. The crowd optimizes for the cheap shot, so the surfaces behind a little setup stay relatively untouched.

What it is, and what it isn’t

Calibrate honestly. A credential that keeps working after it was revoked is a real access-control failure, and the severity is a function of two things: how long the window is, and what the still-valid access lets you do. A read token that lingers thirty seconds is low. A deleted API key that authenticates indefinitely, or a removed admin whose old session can still change billing, is high, and it’s the kind of high that’s clean to argue because there’s no “intended behavior” defense. Nobody intends revoke to be a suggestion.

Confirm the gap and stop. You don’t need to ransack the account to prove the door didn’t lock. One revoked-but-working action, timestamped, with the window measured, is the whole report.

The bug isn’t really the lesson. The lesson is the direction. Everyone’s trying to pick the lock on the front door. Be the one who checks whether the back door actually slammed shut behind him. Next target: don’t just test how they let you in. Test how they kick you out.