JWTs and the Blacklist Problem
Convey's auth packages in two acts - a JWT layer that turns one config section into token issuing and validation, and the uncomfortable question every stateless system meets on logout day.
JSON Web Tokens have one magnificent property and one embarrassing one, and they are the same property: the token is self-contained. No session store, no auth-service call on every request — any service holding the signing key (or the public half of it) can validate a token locally. That is why JWTs conquered microservices. It is also why, the day product asks for a “log out everywhere” button, or security asks you to kill a leaked token now, you rediscover that a JWT cannot be un-issued. It is valid until it expires, and no amount of deleting it from the client changes what a copied token can do.
Convey — the open-source .NET microservices toolkit from DevMentors whose source this series walks through — ships both acts of this story: Convey.Auth for the stateless part, and a small, honest piece of state for the part that cannot be stateless. Notably, the toolkit's own samples never exercise these packages (they demo certificate auth instead — part 18), so everything here comes from the source.
Act one: one section, both directions
AddJwt() binds the jwt section and wires the standard JwtBearer authentication scheme from it. The options class is large because it exposes essentially all of TokenValidationParameters, but the decisions live in a few keys:
"jwt": {
"issuerSigningKey": "<symmetric-signing-key-from-a-secret-store>",
"issuer": "identity-service",
"validIssuer": "identity-service",
"validateAudience": false,
"expiryMinutes": 60
}
The signing-key selection logic is the part worth reading. If a certificate is configured — by file location or base64 rawData — the key becomes an X509SecurityKey and the algorithm defaults to RsaSha256; the code even logs whether the loaded certificate has a private key (this instance can issue) or only the public part (this instance can merely validate). That asymmetry is the correct multi-service topology: the identity service holds the private key; every other service gets the public certificate and can verify tokens without being able to mint them. If no certificate is present, issuerSigningKey becomes a symmetric HmacSha256 key — simpler, but now every validating service holds key material that can also forge tokens, which is a very different blast radius. The package supports both; the config chooses your threat model, quietly.
Issuing is IJwtHandler.CreateToken(userId, role, audience, claims) — standard sub/unique_name/jti/iat claims, expiry from expiryMinutes — and validation is symmetric via the same parameters. Two defaults deserve flags. ClockSkew is set to TimeSpan.Zero: precise, and a reliable generator of mystery 401s the moment a client's clock drifts a few seconds — the framework's five-minute default exists for a reason. And authenticationDisabled: true installs a policy evaluator that returns success for every authenticate and authorize call — a pleasant local-dev switch that must never, ever ride a copied appsettings file into production. (Also worth knowing: JsonWebToken.RefreshToken is always an empty string — there is no refresh-token flow here; you bring your own.)
Act two: the state you swore off
So: revocation. The only honest options for killing a live JWT are (a) short expiries plus refresh tokens, so the damage window is minutes, (b) a denylist consulted on every request, or (c) key rotation, the nuclear option that logs out everyone. Convey implements (b) with a design whose simplicity I appreciate more every time I see a homegrown token table:
// Convey.Auth/Services/InMemoryAccessTokenService.cs
public Task DeactivateAsync(string token)
{
_cache.Set(GetKey(token), "revoked", new MemoryCacheEntryOptions
{
AbsoluteExpirationRelativeToNow = _expires
});
return Task.CompletedTask;
}
private static string GetKey(string token) => $"blacklisted-tokens:{token}";
Deactivation writes a cache entry; IsActiveAsync is a cache miss check; AccessTokenValidatorMiddleware pulls the bearer token from the request and returns 401 when the entry exists. The elegant detail is the expiration: the denylist entry lives exactly as long as a token possibly could (AbsoluteExpirationRelativeToNow = _expires, from the JWT expiry settings). After that, the token is dead by its own exp claim and the entry would be redundant — so the denylist is self-pruning and bounded by construction. Every hand-rolled revocation table I have audited eventually needed a cleanup job; this one cannot grow stale by design.
The in-memory version, of course, only revokes tokens on the instance that processed the logout — useless behind a load balancer. That is the entire reason Convey.Auth.Distributed exists, and it is barely forty lines:
// Convey.Auth.Distributed/DistributedAccessTokenService.cs
public async Task<bool> IsActiveAsync(string token)
=> string.IsNullOrWhiteSpace(await _cache.GetStringAsync(GetKey(token)));
Same contract, backed by IDistributedCache — Redis in practice, registered by the one-liner from part 13. AddDistributedAccessTokenValidator() overrides the in-memory registration, and now a logout on instance A is a 401 on instance Z within one Redis round-trip. You have reintroduced a per-request network dependency into your “stateless” auth — that is the tax, there is no avoiding it, and a Redis outage now degrades either availability (fail closed) or revocation (fail open; this implementation's cache-miss-means-active logic fails open, which you should decide you agree with).
The traps in the seams
Three wiring subtleties determine whether any of this actually works, and none of them produce compile errors.
The middleware is opt-in. JWT bearer validation and the denylist are independent layers; AddJwt() alone gives you signature checking and no revocation. Unless UseAccessTokenValidator() is in the pipeline, DeactivateAsync writes entries nothing reads. A team can ship “logout” that provably does nothing.
Registration order decides the winner. The distributed service overrides the in-memory one by simple last-registration-wins, so AddDistributedAccessTokenValidator() must come after AddJwt(). Reverse them and you silently get per-instance revocation in a multi-instance fleet — correct on your machine, broken in production.
The key is the whole token. Both implementations key the denylist on the raw JWT string. It works — you revoke exactly the token presented — but a kilobyte-long Redis key per revocation is wasteful, and revoking all of a user's tokens (the real “log out everywhere” requirement) needs a different index entirely, such as keying on the jti or sub claim with an issued-after cutoff. This design revokes tokens; it does not revoke users.
What I take from it
The two-package split is the lesson: keep the stateless fast path pure, and isolate the unavoidable state behind the smallest possible interface — IsActive/Deactivate — so its backing store is swappable and its cost is visible. The self-expiring denylist entry is a genuinely elegant trick worth stealing verbatim. And the opt-in middleware is a reminder that in security code, defaults are the product: a revocation feature that requires three separate calls to be wired correctly will, somewhere in your fleet, be wired incorrectly.
Next part stays in the trust business but drops below users entirely: mutual TLS between services, certificate ACLs, and the middleware that decides whether the machine calling you is allowed in.