JWTs, Refresh Tokens, and Revocation Without the Ceremony
The FastEndpoints.Security package covers the token lifecycle end to end - creation with symmetric or RSA keys, an abstract refresh-token service that is secretly an endpoint, and a revocation middleware that makes you pay the lookup tax honestly. Part 8 of FastEndpoints in Depth.
Rolling your own JWT issuance in ASP.NET Core is a rite of passage that nobody should complete. Not because the primitives are missing — Microsoft.IdentityModel has everything — but because the assembly is fiddly: descriptor objects, signing credentials, claim identity subtleties, and a refresh-token flow you will design at least twice. The FastEndpoints.Security package is a compact answer to all of it, and — in keeping with this series — its source is short enough to actually read. This part covers what it does and the two design choices I found genuinely instructive.
The obligatory framing: this is for services that issue their own tokens — first-party auth, service-to-service, mobile backends. If you have an identity provider (Entra, Auth0, Keycloak), keep it; the declarative authorization from Part 7 works with any JWT source, and this package simply isn't needed.
Creation: one static method, guarded
Token creation is a single call:
var token = JwtBearer.CreateToken(o =>
{
o.SigningKey = jwtKey; // from configuration, obviously
o.ExpireAt = DateTime.UtcNow.AddMinutes(15);
o.User.Roles.Add("sales");
o.User.Permissions.Add("order:create", "order:refund");
o.User.Claims.Add(new("sub", userId));
o.Issuer = "https://api.example.com";
o.Audience = "example-spa";
});
Under the hood (Src/Security/JwtBearer.cs) this maps Roles/Permissions/Claims onto claim lists — permissions become claims of the configured permissions claim type, which is exactly what the Part 7 policies check; the two packages meet in that one string. Creation options can also come from DI (IOptions<JwtCreationOptions>), so your issuing endpoint doesn't hard-code key material, and the source resolves TimeProvider for the IssuedAt stamp — a small detail that makes token expiry testable with a fake clock, which anyone who has written time-freezing tests around auth will appreciate.
Two guards in the source are worth quoting in spirit: a missing signing key throws immediately with a message naming the property, and choosing asymmetric signing while leaving the default HmacSha256 algorithm throws too — refusing a nonsensical combination rather than emitting a token that validators will reject confusingly later. Asymmetric keys are supported both PEM-encoded and raw base64 RSA, with an optional key-ID generator for rotation scenarios.
Validation-side setup is the mirror one-liner — AddAuthenticationJwtBearer(s => s.SigningKey = ...) — plus cookie auth if you want tokens stored in HttpOnly cookies for browser clients.
The refresh service is an endpoint
The design choice I want to spotlight: RefreshTokenService<TokenRequest, TokenResponse> is an abstract endpoint. You inherit it, and its sealed Configure() registers a token-refresh route on your behalf:
public class MyTokenService : RefreshTokenService<TokenRequest, TokenResponse>
{
public MyTokenService()
{
Setup(o =>
{
o.TokenSigningKey = jwtKey;
o.AccessTokenValidity = TimeSpan.FromMinutes(15);
o.RefreshTokenValidity = TimeSpan.FromHours(8);
o.Endpoint("/auth/refresh", ep => ep.Summary(s => s.Summary = "renew tokens"));
});
}
public override async Task PersistTokenAsync(TokenResponse rsp)
=> await store.SaveAsync(rsp.UserId, rsp.RefreshToken, rsp.RefreshExpiry);
public override async Task RefreshRequestValidationAsync(TokenRequest req)
{
if (!await store.IsValidAsync(req.UserId, req.RefreshToken))
AddError(r => r.RefreshToken, "Refresh token is invalid!");
}
public override async Task SetRenewalPrivilegesAsync(TokenRequest req, UserPrivileges p)
=> p.Permissions.AddRange(await perms.CurrentForAsync(req.UserId));
}
The base class owns the flow — receive refresh request, validate, mint a new pair, persist, respond — and forces you to answer exactly the three questions the flow cannot answer for you: where do refresh tokens live (PersistTokenAsync), is this one still good (RefreshRequestValidationAsync), and what privileges does the renewed access token carry (SetRenewalPrivilegesAsync). That third hook is quietly important: privileges are re-resolved at refresh time, so a permission revoked in your database ages out of circulation within one access-token lifetime, no token blacklist required. Short access tokens plus this hook is the 90% revocation story.
From your login endpoint, minting the initial pair is CreateTokenWith<MyTokenService>(userId, p => { ... }) — the same service, same persistence, same hooks (OnBeforeInitialTokenCreationAsync and friends are virtual for customization). One class owns the entire token lifecycle. Compare that with the usual scatter — token helper here, refresh controller there, privilege lookup duplicated between them — and the “service as endpoint” shape justifies itself.
What the base class deliberately does not give you: refresh-token rotation detection (reuse of an already-spent refresh token flagging a stolen credential). RefreshRequestValidationAsync is where you implement it — mark tokens spent in PersistTokenAsync's store, reject reuse, optionally kill the family. The hooks are sufficient; the policy is yours. I would have liked the docs to shout about this more, because a naive IsValidAsync implementation quietly forgoes rotation safety.
Revocation: the honest tax
For the 10% case where “wait fifteen minutes” is unacceptable — compromised tokens, banned users, tenant kill-switches — the package ships JwtRevocationMiddleware:
public class RevocationCheck : JwtRevocationMiddleware
{
public RevocationCheck(RequestDelegate next) : base(next) { }
protected override async Task<bool> JwtTokenIsValidAsync(string jwt, CancellationToken ct)
=> !await cache.IsRevokedAsync(jwt, ct); // your store: Redis, DB, memory
}
I like this API precisely because of what it refuses to hide. Revocation checking means a lookup on every authenticated request — that is the unavoidable cost of making stateless tokens stateful. The library doesn't bundle a store or pretend the cost away; it hands you an abstract method with the bearer token and makes the tax explicit. Your Redis EXISTS at sub-millisecond latency is fine; your SQL table scan is not; and the design makes sure that decision has an owner — you.
The middleware slots in before authorization, answers 401 with a configurable message for revoked tokens, and is entirely optional. Revoked-token storage only needs to remember tokens until their natural expiry, which is why short access-token lifetimes make everything in this story cheaper.
The lifecycle, assembled
Putting the three pieces together, the shape I now deploy for first-party auth:
- 15-minute access tokens carrying permissions as claims (Part 7 enforces them for free).
- A
RefreshTokenServicepersisting hashed refresh tokens with rotation-reuse detection inRefreshRequestValidationAsync, and privileges re-read from the source of truth inSetRenewalPrivilegesAsync. - Revocation middleware only if the product demands instant lockout — backed by Redis with TTLs matching token expiry.
None of this is novel security engineering — it is the standard OAuth-adjacent shape — but it is remarkable how little code the package makes it. My login endpoint is a dozen lines. The refresh service is forty. And every piece is still standard ASP.NET Core underneath: standard bearer authentication, standard policies, middleware in the standard pipeline. The library's recurring philosophy — drive the platform, don't replace it — holds in the security corner too.
Next, the series turns to a subject with more opinions per line of code than any other: API versioning, where FastEndpoints does something unusual — it puts the version at the end of the route, and its “release group” model is worth understanding before you fight it. Part 9: Versioning at the End of the Route.