Skip to content

Token Inventory and Incident Revocation

Abblix OIDC Server issues self-contained JWTs and deliberately keeps no list of them. Individual revocation works out of the box: the RFC 7009 endpoint marks a token's jti revoked, and every server-side validation checks that mark. The inventory question, though, has no built-in answer: which tokens does this user hold right now? Which clients received tokens today? Revoke everything issued to this subject. The day you need those answers is usually an incident, which is the wrong day to start building.

This guide builds the missing piece on the seams designed for it: recording decorators over the token-issuance services, one queryable table, and a bulk-revocation routine that writes through the exact registry the server already consults.

How revocation actually works: the path to plug into

Two library pieces carry the whole mechanism, and the recipe must match both:

  • ITokenRegistry stores a status per jti: GetStatusAsync(jwtId) and SetStatusAsync(jwtId, status, expiresAt), with Revoked and Used as the meaningful statuses. The entry lives in operational storage exactly as long as the token itself, then expires away; revocation bookkeeping cleans itself up.
  • A status-checking decorator on the JWT validator consults that registry on every server-side validation of a token that carries a jti. That single choke point is what makes a mark effective everywhere at once: a revoked refresh token fails redemption with invalid_grant, a revoked access token turns active: false at introspection and invalid_token at userinfo, and the same rejection covers token-exchange subject tokens as well.

So bulk revocation needs no new enforcement: one SetStatusAsync(jti, Revoked, expiresAt) per token rides the same rails as the revocation endpoint. What is missing is only the list of jtis to feed it. That is the inventory.

Record at issuance

Every access and refresh token the server mints passes through one of two seams: IAccessTokenService.CreateAccessTokenAsync and IRefreshTokenService.CreateRefreshTokenAsync. Both receive the full context (AuthSession for the subject, AuthorizationContext for client and scopes, plus ClientInfo) and return the encoded token. One detail shapes the decorator: the jti is generated inside the library service, so nothing can be recorded before the inner call. Your implementation of the interface delegates to the inner service first and reads the record off the result - JwtId and ExpiresAt from the returned token's payload, the subject and the client and scopes from the arguments it was handed. Refresh-token issuance can legitimately return nothing, when the expiration policies have already elapsed, and that case records nothing. The second method of each interface, AuthorizeByRefreshTokenAsync and AuthenticateByAccessTokenAsync, is redemption rather than issuance: the inventory has nothing to write there, so it passes straight through.

Recording at the issuance seam rather than deeper down is deliberate: this is the only place where the subject, client, and scopes are all in hand next to the fresh jti; the registry level sees nothing but ids and statuses.

Both services are registered with TryAdd, and Decorate (from the library's Abblix.DependencyInjection toolkit) wraps whatever is currently registered, so the two Decorate calls, along with the registration of your own inventory store, belong in Program.cs after AddOidcServices.

C#
// Program.cs, after AddOidcServices(...)
builder.Services.AddSingleton<ITokenInventory, PostgresTokenInventory>();
builder.Services.Decorate<IRefreshTokenService, RecordingRefreshTokenService>();
builder.Services.Decorate<IAccessTokenService, RecordingAccessTokenService>();

The decorator forces a decision: what happens when the inventory write fails. As written, the exception propagates and issuance fails: the inventory and reality stay consistent, at the price of coupling the token endpoint's availability to the inventory store; worse, a failure during refresh-token rotation lands after the old token is already marked, so the client loses its refresh chain. Do not soften that with a silent catch or a fire-and-forget write: a token issued but never recorded is invisible to the sweep, the one failure this build-out exists to prevent. If availability must win, route the failed record into a durable dead-letter store (an outbox table the sweep unions over) so nothing issued escapes the inventory, and alert on every fallback write.

The inventory table

The store behind ITokenInventory is yours; a single table answers every inventory question this guide opened with:

SQL
CREATE TABLE issued_tokens (
    jwt_id     text PRIMARY KEY,
    kind       text NOT NULL,          -- 'access' | 'refresh'
    subject    text NOT NULL,
    client_id  text NOT NULL,
    scopes     text[] NOT NULL,
    issued_at  timestamptz NOT NULL DEFAULT now(),
    -- NOT NULL: both recorded seams always mint an expiry, and a nullable column
    -- would leave rows the nightly prune never removes
    expires_at timestamptz NOT NULL,
    revoked_at timestamptz
);

CREATE INDEX ON issued_tokens (subject, expires_at);
CREATE INDEX ON issued_tokens (client_id, expires_at);
CREATE INDEX ON issued_tokens (expires_at);   -- the nightly prune scans by expiry alone

Rows are prunable the moment expires_at passes: an expired token needs no inventory, and the registry's own status entries expire with the token anyway. A nightly DELETE WHERE expires_at < now() keeps the table at the size of your live token population.

One honest sizing note: with short access-token lifetimes this table takes a write per issued token, which on a busy server is the highest-frequency write in this whole cookbook. If access-token rows earn nothing for you (many deployments only ever revoke and audit refresh tokens, accepting the few minutes of access-token tail), record the refresh decorator alone and halve the write volume. The seams make that a one-line choice.

Treat the table itself as sensitive. It is a subject-to-token map (user identifiers, the clients they use, their scopes), and revoked_at makes it an incident-history ledger on top. Restrict it to a least-privilege role and keep it out of reporting and analytics grants. Bring subject under the same retention and erasure policy as your other user data: the expiry prune removes expired rows, not your incident history; set an explicit retention window for that.

Incident revocation

With the inventory in place, bulk revocation is a query plus two writes per token. List the subject's still-active rows, and for each one call ITokenRegistry.SetStatusAsync with the token's jti, the status Revoked and the token's own expiry - the same write the RFC 7009 endpoint makes, and the same mark every server-side validation consults. Passing the token's expiry keeps the mark alive exactly as long as the token it condemns; if you ever inventory a token kind whose expiry you did not record, fail long and pass your maximum token lifetime, because a mark that dies before its token silently un-revokes it. The second write stamps revoked_at on the inventory row, which is what makes the sweep auditable afterwards.

Revoking everything issued to a client is the same loop over a different query. At incident scale, parallelize it (Parallel.ForEachAsync with a bounded degree) rather than awaiting one token at a time; both writes are idempotent, so the sweep is safe to re-run after any interruption.

And see these for what they are, mass-revocation primitives: one call logs a subject out of everything, or a whole client's user base. Expose them only behind your strongest operator authentication and authorization, never bind the subject or client id to an unauthenticated request parameter, and audit-log every invocation with the operator's identity.

What the sweep reaches, and what it cannot

Immediately after the sweep, on the server itself:

  • every revoked refresh token fails redemption with invalid_grant, cutting the renewal chain;
  • every revoked access token answers active: false at introspection and invalid_token at userinfo;
  • the same marks reject revoked tokens presented as token-exchange subject tokens.

What no server-side mark can reach is a resource server validating JWT signatures locally: it never asks the server, so it honors a revoked access token until the token's own expiry. That exposure is bounded by the access-token lifetime, which is exactly why the Production Hardening Checklist caps it at minutes; resource APIs that must see revocation immediately should validate via introspection instead.

And the sweep is a point-in-time cut, not containment: it does nothing to stop new issuance. A compromised account with a live SSO session (or a compromised client with valid credentials) obtains fresh tokens seconds later. Terminate the user's sessions or suspend the client's credentials first, then sweep; or sweep again after containment.

Rotation and cache persistence both interact with the sweep. Refresh-token rotation, which is on unless a client turns it off, already marks a rotated-out token Used and revokes the whole grant family when one is replayed, so the inventory complements rotation rather than replacing it. Keep the two marks apart in any registry tooling you build: the sweep writes Revoked, rotation writes Used, and a query keyed on Revoked alone sees only half of what the registry knows. And because the registry rides on operational storage, the persistence requirement from Choosing a Backend for Operational State applies doubly here: a wiped cache forgets revocation marks, and no inventory row will re-write them by itself.

Verify the loop end to end

  • complete an authorization-code flow with offline_access for a test user: the inventory must show one refresh and one access row;
  • run the subject sweep for that user;
  • present the refresh token at the token endpoint: invalid_grant;
  • introspect the access token: active: false; call userinfo with it: rejected;
  • confirm the inventory rows carry revoked_at.

That sequence is also the E2E test worth keeping: it proves the decorators, the store, and the registry writes compose, not just that each piece works alone.

Where this sits in the bigger picture

The inventory rides on the rest of the cookbook: the revocation marks it writes live in operational storage, where persistence is load-bearing (Choosing a Backend for Operational State), and the clients these tokens belong to have their own store with its own duties (A Durable Client Store).