Revoking the Irrevocable
DShop.Common makes a stateless JWT revocable with a Redis deny-list and a per-request round-trip - plus an iat claim in milliseconds and two validators that disagree.
A JSON Web Token's whole selling point is that it is stateless: the signature proves the token is genuine, so the server can trust it without looking anything up. Its whole problem is the same sentence read backwards: if you never look anything up, you can never revoke a token before it expires. Log a user out, ban an account, rotate a compromised session — a stateless JWT ignores all of it until exp passes. DShop.Common wants revocation anyway, and the way it gets it is genuinely reasonable, genuinely costly, and wrapped around two smaller bugs worth seeing. This is the estate's security core, and it is a fair fight between a real requirement and the pattern that meets it.
Issuing: HS256 and a hand-built claim set
JwtHandler.CreateToken signs with HMAC-SHA256 — a symmetric algorithm, one shared secret both signs and verifies:
var issuerSigningKey = new SymmetricSecurityKey(Encoding.UTF8.GetBytes(_options.SecretKey));
_signingCredentials = new SigningCredentials(issuerSigningKey, SecurityAlgorithms.HmacSha256);
// ...
var jwtClaims = new List<Claim>
{
new Claim(JwtRegisteredClaimNames.Sub, userId),
new Claim(JwtRegisteredClaimNames.UniqueName, userId),
new Claim(JwtRegisteredClaimNames.Jti, Guid.NewGuid().ToString()),
new Claim(JwtRegisteredClaimNames.Iat, now.ToTimestamp().ToString()),
};
The shape is fine — subject, a unique token id (jti, which is what makes per-token revocation addressable), issued-at, an optional role. HS256 is a defensible choice for a closed estate where issuer and validators are the same team. It has one consequence that echoes through the third series: every service that validates a token needs the shared secret, so that secret is copied everywhere — and when it is also committed to the repository, as it is here, the entire trust model collapses. That is a series-three story; for now, note only that symmetric signing makes the key a estate-wide secret by design.
Bug the first: iat in milliseconds
That now.ToTimestamp() is doing something subtly wrong. Here is ToTimestamp:
public static long ToTimestamp(this DateTime dateTime)
{
var centuryBegin = new DateTime(1970, 1, 1, 0, 0, 0, DateTimeKind.Utc);
var expectedDate = dateTime.Subtract(new TimeSpan(centuryBegin.Ticks));
return expectedDate.Ticks / 10000;
}
A DateTime tick is 100 nanoseconds; there are 10,000 ticks in a millisecond and ten million in a second. Dividing ticks by 10,000 yields milliseconds since the epoch. But the JWT specification (RFC 7519) defines iat, exp and nbf as NumericDate — seconds since the epoch. So the iat this code stamps is a thousand times too large; a token issued today claims to be issued somewhere around the year 52,000. It doesn't break anything, because almost no validator enforces iat and the library sets exp itself (correctly, in seconds, via the JwtSecurityToken constructor) — which means a single token carries exp in seconds and iat in milliseconds, two different units in the same envelope. Harmless today, a genuine interoperability landmine the moment anything downstream trusts iat. Units are a contract; this one is off by 1000.
The clever part: a Redis deny-list
Revocation is bolted on with AccessTokenService, and the design is the standard, sound answer to “revoke a stateless token”:
public async Task<bool> IsActiveAsync(string token)
=> string.IsNullOrWhiteSpace(await _cache.GetStringAsync(GetKey(token)));
public async Task DeactivateAsync(string userId, string token)
=> await _cache.SetStringAsync(GetKey(token), "deactivated",
new DistributedCacheEntryOptions
{
AbsoluteExpirationRelativeToNow = TimeSpan.FromMinutes(_jwtOptions.Value.ExpiryMinutes)
});
private static string GetKey(string token) => $"tokens:{token}";
Read the polarity carefully, because it inverts what you expect: a token is active when its key is absent. Logging out writes tokens:{jwt} = "deactivated" into Redis; every authenticated request checks whether that key exists. The genuinely tidy touch is the TTL: the deny-list entry expires after ExpiryMinutes — exactly when the token would have died naturally — so Redis only ever holds revoked-but-not-yet-expired tokens and cleans itself. You revoke by remembering a token until it would have expired anyway. That is the correct pattern, and I'd ship it.
The cost you can't design away
The price is stamped on the design: IsCurrentActiveToken runs on the authenticated path, and it is an await _cache.GetStringAsync(...) — a network round-trip to Redis on every protected request. You started with a stateless token specifically so you would not have to look anything up, and revocation put the lookup back. That is not a flaw in the code; it is the inherent tax of wanting stateless and revocable at once. You have, in effect, reinvented server-side sessions with a signature on top — and if Redis is down, either every request fails closed (no auth) or fails open (no revocation), and the estate must pick its poison. Worth saying plainly, because teams reach for JWTs to avoid the session lookup and then add it straight back without noticing they've paid twice. To be fair: revocation is a real requirement JWTs genuinely don't support, and a TTL'd deny-list is the least-bad way to get it. The lesson is not “don't do this” — it's “know that you did.”
Bug the second: two validators that disagree
The estate validates tokens in two places, with two different TokenValidationParameters, and they don't match. JwtHandler.GetTokenPayload builds one profile — issuer, audience, lifetime — and leaves ClockSkew at its default five minutes. AddJwt's AddJwtBearer registration builds another profile with the same fields plus one extra line: ClockSkew = TimeSpan.Zero.
// AddJwt — the ASP.NET Core middleware profile:
cfg.TokenValidationParameters = new TokenValidationParameters
{
IssuerSigningKey = ..., ValidIssuer = ..., ValidateLifetime = options.ValidateLifetime,
ClockSkew = TimeSpan.Zero // <- zero tolerance
};
// JwtHandler — the manual profile: no ClockSkew set, so it is the 5-minute default.
So in the five-minute window around a token's expiry, the two paths disagree: the AddJwtBearer middleware, with zero skew, rejects a just-expired token; JwtHandler.GetTokenPayload, with five minutes of slack, still accepts it. Whether a token is valid depends on which door it knocks on. It is a small window and a small inconsistency, but “the answer to is this token valid is not a function of the token alone” is exactly the kind of thing that produces a bug report nobody can reproduce, because it only happens for five minutes and only on one code path.
What Convey learned
Line the three findings up — a hand-built claim with a units bug, a revocation scheme that reintroduces the lookup JWTs exist to avoid, two validators configured apart — and you have a security core that is right in its instincts and loose in its wiring. When these authors rebuilt auth for Convey and the modern estates, token handling consolidated: one validation configuration, correct timestamp helpers, and the deny-list pattern kept but centralised so there is exactly one profile and one place the Redis cost is paid. The idea survived; the duplication did not.
Two parts left. Next, the composition root itself — nine throwaway DI containers, three coexisting container frameworks, and a vendored copy of Microsoft's config parser, all in the name of reading a config file before the container exists.