Skip to content
kc@kumarChandrachooda.com:~$ cd /blog/jwts-refresh-tokens-and-revocation && read --section="top" 0%
.NET

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.

By Kumar Chandrachooda 16 Jun 2025 5 min read

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 RefreshTokenService persisting hashed refresh tokens with rotation-reuse detection in RefreshRequestValidationAsync, and privileges re-read from the source of truth in SetRenewalPrivilegesAsync.
  • 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.

Which CV would you like?

Pick the one closest to the role. Each is two pages and carries the same facts.

Applied AI

Agentic AI & Cloud Engineer · multi-agent systems, RAG and MCP on AWS

Cloud & .NET

C#, .NET 10 and ASP.NET Core microservices on AWS and Azure · Terraform & AWS CDK

Data Engineering

AWS lakehouse · Kinesis, Glue (PySpark), Apache Iceberg, Lake Formation

Everything

All three domains, with the standout project from each