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 align with both:
ITokenRegistrystores a status perjti:GetStatusAsync(jwtId)andSetStatusAsync(jwtId, status, expiresAt), withRevokedandUsedas 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 withinvalid_grant, a revoked access token turnsactive: falseat introspection andinvalid_tokenat 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 the record is written after the inner call, from the returned token's payload:
public sealed class RecordingRefreshTokenService(
IRefreshTokenService inner,
ITokenInventory inventory) : IRefreshTokenService
{
public async Task<EncodedJsonWebToken?> CreateRefreshTokenAsync(
AuthSession authSession, AuthorizationContext authContext,
ClientInfo clientInfo, JsonWebToken? refreshToken)
{
var issued = await inner.CreateRefreshTokenAsync(authSession, authContext, clientInfo, refreshToken);
if (issued is not null)
{
// the jti is minted inside the inner service, so read it from the result
var payload = issued.Token.Payload;
await inventory.RecordAsync(new IssuedTokenRecord(
payload.JwtId!, Kind: "refresh",
authSession.Subject, authContext.ClientId, authContext.Scope,
ExpiresAt: payload.ExpiresAt));
}
return issued;
}
// redemption is read-only for the inventory, so pass it through
public Task<Result<AuthorizedGrant, OidcError>> AuthorizeByRefreshTokenAsync(JsonWebToken refreshToken) =>
inner.AuthorizeByRefreshTokenAsync(refreshToken);
}
The access-token twin is the same shape over IAccessTokenService (record after the inner CreateAccessTokenAsync, pass AuthenticateByAccessTokenAsync 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 decorators go in after AddOidcServices:
// Program.cs, after AddOidcServices(...)
builder.Services.AddSingleton<ITokenInventory, PostgresTokenInventory>();
builder.Services.Decorate<IRefreshTokenService, RecordingRefreshTokenService>();
builder.Services.Decorate<IAccessTokenService, RecordingAccessTokenService>();
One decision the decorator forces: 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 (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:
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, keep it out of reporting and analytics grants, and 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 the registry write the server already trusts:
public sealed class IncidentRevocationService(
ITokenInventory inventory,
ITokenRegistry tokenRegistry)
{
public async Task RevokeAllForSubjectAsync(string subject)
{
foreach (var token in await inventory.ListActiveAsync(subject))
{
// the same write the RFC 7009 endpoint makes, and the same mark every
// server-side validation consults; the expiry keeps the mark alive exactly
// as long as the token it condemns. If you ever inventory a token kind
// without a recorded expiry, fail LONG (your maximum token lifetime):
// a mark that dies before its token silently un-revokes it
await tokenRegistry.SetStatusAsync(
token.JwtId, JsonWebTokenStatus.Revoked, token.ExpiresAt);
await inventory.MarkRevokedAsync(token.JwtId);
}
}
}
RevokeAllForClientAsync 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 treat 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: falseat introspection andinvalid_tokenat 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.
Two interactions worth knowing. Refresh-token rotation (AllowReuse = false) already marks rotated-out tokens on its own (Revoked on shipped 2.3; the 2.4 branch marks them Used instead and adds whole-grant-family revocation on replay), so the inventory complements rotation rather than replacing it. Keep the distinction in any registry tooling you build: the sweep writes Revoked, and a query keyed on Revoked alone will not see rotation's marks on 2.4. 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_accessfor a test user: the inventory must show one refresh and one access row; - run
RevokeAllForSubjectAsyncfor 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).