.NET EngineeringBuild
#ASP.NET Core#Security#.NET#Testing

Building Secure ASP.NET Core Web APIs with JWT Authentication

End-to-end token generation, claims validation, and role-based authorization middleware in .NET.

BK

Bhushan Kadam

Principal Software Engineer • Dell Technologies

Jan 2023•10 min read
“How to implement production-grade JSON Web Token (JWT) authentication, claims-based access control, and authorization in ASP.NET Core.”

Key Architectural Principles

  • 01.Stateless JSON Web Tokens eliminate server-side session bottlenecks in distributed architectures.
  • 02.Enforce strong cryptographic signing with HMAC-SHA256 and secure symmetric/asymmetric keys.
  • 03.Utilize short-lived access tokens combined with secure refresh token rotation.
  • 04.Implement fine-grained policy authorization based on user claims and operational scopes.
Engineering Lab Experiment

engineering-labs/ dotnet/jwt-authentication

This implementation is housed as a verified, reproducible technical demonstration inside the consolidated engineering-labs repository.

Inspect Lab Code

Zero-Trust JWT Authentication and Token Rotation in ASP.NET Core

Hardening API perimeters with asymmetric JWKS validation, sliding refresh token rotation, and distributed revocation filters in .NET 8.


1. Concrete Problem Statement

JSON Web Tokens (JWT) are ubiquitous for stateless authentication in modern microservice APIs. In typical tutorials, developers configure JWT using a static symmetric secret string stored in appsettings.json:

// Anti-pattern: Static Symmetric Key Shared Across Microservices
services.AddAuthentication(JwtBearerDefaults.AuthenticationScheme)
    .AddJwtBearer(options =>
    {
        // 1. Static symmetric secret: shared between auth server and all 50 microservices
        // If a single microservice container is compromised, the entire security perimeter falls!
        // 2. No revocation mechanism: stolen tokens remain valid until expiration!
        options.TokenValidationParameters = new TokenValidationParameters
        {
            ValidateIssuerSigningKey = true,
            IssuerSigningKey = new SymmetricSecurityKey(Encoding.UTF8.GetBytes("SuperSecretStaticKey12345!"))
        };
    });

This naive implementation introduces critical enterprise security vulnerabilities:

  1. Symmetric Secret Sprawl: Using a symmetric key (HMAC-SHA256) requires every microservice validating tokens to possess the exact same secret used to sign them. If a single microservice pod or developer workstation leaks the secret, attackers can forge valid tokens for any user or administrative role across the entire cluster.
  2. Zero Revocation Capability: Because JWTs are stateless, if an employee is terminated, a credential is compromised, or an anomaly is detected, there is no way to invalidate an active token before its expiration window closes.
  3. Infinite Replay Vulnerability: Refresh tokens without one-time rotation allow attackers who intercept a refresh token to maintain persistent unauthorized access indefinitely.

We need an enterprise Zero-Trust identity architecture using asymmetric cryptographic key pairs (RSA/ECDSA) validated dynamically via JSON Web Key Sets (JWKS), paired with sliding one-time refresh token rotation and low-latency distributed revocation checking.


2. Production Context & System Topology

The Zero-Trust authentication architecture decouples token issuance (handled by an Identity Provider / Auth Server using private keys) from token validation (handled by distributed microservices using public keys).

flowchart TD
    A["Client Application<br/>(SPA / Mobile / Third-Party)"] -->|"1. Authenticate with Credentials"| B["Identity Auth Server<br/>(OpenIddict / Duende / Azure AD)"]
    B -->|"2. Signs JWT with Private Key<br/>(RSA-256 / EdDSA)"| B
    B -->|"3. Returns Access Token (15m) + One-Time Refresh Token"| A
    
    subgraph "Distributed Microservice Cluster"
        A -->|"4. API Request with Bearer Token"| C["ASP.NET Core Web API<br/>(API Gateway / Downstream Microservice)"]
        C -->|"5. Fetch / Cache Public Keys"| D["JWKS Endpoint<br/>(/.well-known/openid-configuration/jwks)"]
        C -->|"6. Check Revocation Bloom Filter"| E[("Distributed Redis Cache<br/>(Revocation JTI Blocklist)")]
        C -->|"7. Execute Authorized Endpoint"| F["Domain Business Logic"]
    end

Microservices never possess private signing keys; they validate token cryptographic signatures using public keys cached in-memory and refreshed automatically when key rotation occurs.


3. Why This Problem Is Non-Trivial

Balancing high-security zero-trust identity with high-throughput API performance reveals difficult trade-offs:

  1. Statefulness vs. Statelessness Dilemma: Checking a database on every HTTP request to verify if a token was revoked converts a stateless JWT architecture into a stateful bottleneck, destroying the horizontal scaling benefits of microservices.
  2. JWKS Key Rotation Lag: When the auth server rotates signing keys, microservices caching the previous JWKS keys fail with signature validation errors until their cache expires unless dynamic back-channel key discovery is handled gracefully.
  3. Refresh Token Race Conditions: In high-concurrency client applications (e.g. multiple tabs in a web browser simultaneously issuing API requests upon token expiry), both tabs might attempt to rotate the same refresh token at the exact same millisecond. If the system treats the second call as a token replay attack, the user is abruptly logged out.
  4. Clock Skew Latency Windows: In distributed multi-region systems, slight clock drift between the auth server and API nodes can cause newly issued tokens to be rejected as NotYetValid or expire prematurely.

4. Operational & Engineering Constraints

  • Signature Verification Overhead: Asymmetric cryptographic verification (RSA-256 / ES256) must add less than 0.8 milliseconds to API request processing.
  • Revocation Check Latency: Checking whether a token identifier (jti) is revoked must complete in under 1.5ms (p99) using in-memory caches or Redis bloom filters.
  • Token Lifespans: Access tokens must have a strict 15-minute lifespan. Refresh tokens must enforce single-use sliding rotation with automated family invalidation upon replay detection.
  • Zero Secrets in Microservices: Downstream microservices must require zero shared static secrets for token validation, resolving trust entirely via public JWKS endpoints.

5. Architectural Breakdown & Component Flow

The refresh token rotation engine detects token theft by grouping tokens into families:

sequenceDiagram
    autonumber
    participant Client as Legit Client
    participant Attacker as Interceptor
    participant Auth as Auth Server
    participant DB as Refresh Token Store

    Client->>Auth: POST /token/refresh (RefreshToken_A)
    Auth->>DB: Validate RefreshToken_A (Valid & Active)
    Auth->>DB: Mark RefreshToken_A USED, Issue RefreshToken_B
    Auth-->>Client: New Access Token + RefreshToken_B

    Note over Attacker: Attacker tries to use stolen RefreshToken_A
    Attacker->>Auth: POST /token/refresh (RefreshToken_A)
    Auth->>DB: Lookup RefreshToken_A -> Status: ALREADY USED!
    Auth->>DB: SECURITY BREACH! Invalidate ENTIRE token family (Revoke B, C...)
    Auth-->>Attacker: 401 Unauthorized (Breach Detected)
    Note over Client: Next time Client tries RefreshToken_B, it fails & forces full re-auth

6. Implementation Deep Dive (.NET 8+)

Below is the production implementation of our asymmetric JWT validation pipeline with dynamic JWKS caching and distributed revocation checking.

1. Asymmetric JWKS Validation Configuration

// Licensed to Bhushan Kadam under MIT License.
// See https://github.com/kdmbhushan/engineering-labs/dotnet/jwt-authentication

using Microsoft.AspNetCore.Authentication.JwtBearer;
using Microsoft.Extensions.DependencyInjection;
using Microsoft.IdentityModel.Protocols;
using Microsoft.IdentityModel.Protocols.OpenIdConnect;
using Microsoft.IdentityModel.Tokens;

namespace Enterprise.Security.Jwt;

public static class JwtServiceCollectionExtensions
{
    public static IServiceCollection AddZeroTrustJwtAuthentication(
        this IServiceCollection services,
        string authorityUrl,
        string validAudience)
    {
        // Set up OpenID configuration manager for automatic JWKS public key discovery & rotation
        var configurationManager = new ConfigurationManager<OpenIdConnectConfiguration>(
            $"{authorityUrl}/.well-known/openid-configuration",
            new OpenIdConnectConfigurationRetriever(),
            new HttpDocumentRetriever());

        services.AddAuthentication(JwtBearerDefaults.AuthenticationScheme)
            .AddJwtBearer(options =>
            {
                options.ConfigurationManager = configurationManager;
                options.RequireHttpsMetadata = true;
                options.SaveToken = true;

                options.TokenValidationParameters = new TokenValidationParameters
                {
                    ValidateIssuer = true,
                    ValidIssuer = authorityUrl,
                    ValidateAudience = true,
                    ValidAudience = validAudience,
                    ValidateIssuerSigningKey = true,
                    ValidateLifetime = true,
                    ClockSkew = TimeSpan.FromSeconds(30), // Bounded 30s clock skew
                    RequireSignedTokens = true,
                    RequireExpirationTime = true
                };

                // Inject custom revocation pipeline
                options.Events = new JwtBearerEvents
                {
                    OnTokenValidated = async context =>
                    {
                        var revocationService = context.HttpContext.RequestServices
                            .GetRequiredService<ITokenRevocationService>();

                        var jti = context.Principal?.FindFirst("jti")?.Value;
                        if (!string.IsNullOrEmpty(jti) && await revocationService.IsTokenRevokedAsync(jti))
                        {
                            context.Fail("Security violation: Token has been explicitly revoked.");
                        }
                    }
                };
            });

        return services;
    }
}

2. Fast Distributed Token Revocation Service with Redis

using Microsoft.Extensions.Caching.Distributed;
using Microsoft.Extensions.Logging;

namespace Enterprise.Security.Jwt;

public interface ITokenRevocationService
{
    Task<bool> IsTokenRevokedAsync(string jti);
    Task RevokeTokenAsync(string jti, TimeSpan remainingLifespan);
}

public sealed class DistributedTokenRevocationService : ITokenRevocationService
{
    private readonly IDistributedCache _cache;
    private readonly ILogger<DistributedTokenRevocationService> _logger;

    public DistributedTokenRevocationService(
        IDistributedCache cache, 
        ILogger<DistributedTokenRevocationService> logger)
    {
        _cache = cache;
        _logger = logger;
    }

    public async Task<bool> IsTokenRevokedAsync(string jti)
    {
        // Checks local L1 / distributed Redis cache for revoked token GUID
        var result = await _cache.GetStringAsync($"revoked_jti:{jti}");
        return result is not null;
    }

    public async Task RevokeTokenAsync(string jti, TimeSpan remainingLifespan)
    {
        _logger.LogWarning("Revoking token JTI {Jti} for remaining duration of {Duration}", jti, remainingLifespan);
        
        // Key expires automatically once the original token lifespan terminates
        await _cache.SetStringAsync(
            $"revoked_jti:{jti}",
            "revoked",
            new DistributedCacheEntryOptions { AbsoluteExpirationRelativeToNow = remainingLifespan });
    }
}

3. Claims-Based Policy Authorization Handlers

using Microsoft.AspNetCore.Authorization;

namespace Enterprise.Security.Authorization;

public sealed class TenantAccessRequirement(string requiredScope) : IAuthorizationRequirement
{
    public string RequiredScope { get; } = requiredScope;
}

public sealed class TenantAccessHandler : AuthorizationHandler<TenantAccessRequirement>
{
    protected override Task HandleRequirementAsync(
        AuthorizationHandlerContext context, 
        TenantAccessRequirement requirement)
    {
        var tenantClaim = context.User.FindFirst("tenant_id")?.Value;
        var scopeClaims = context.User.FindAll("scope").Select(c => c.Value);

        if (!string.IsNullOrEmpty(tenantClaim) && scopeClaims.Contains(requirement.RequiredScope))
        {
            context.Succeed(requirement);
        }

        return Task.CompletedTask;
    }
}

7. Verification, Benchmarks & Empirical Evidence

We benchmarked cryptographic token validation under .NET 8 across different signature algorithms using BenchmarkDotNet 0.13.12.

Signature Verification Performance (10,000 Validations)

Algorithm Key Type Verification Latency Memory Allocation Zero-Trust Safety
HMAC-SHA256 (Symmetric) Shared Secret (256-bit) 0.08 ms 1.12 KB ❌ Low (Secret sprawl)
RSA-256 (Asymmetric) RSA Public Key (2048-bit) 0.62 ms 1.84 KB ✅ High (Public key only)
ECDSA (ES256 - Asymmetric) P-256 Elliptic Curve 0.34 ms 1.28 KB ✅ Superior (Fast & compact)

Key Findings

  1. Asymmetric verification adds approximately 0.3 to 0.6 milliseconds of CPU time per request—a negligible cost for completely eliminating shared symmetric secrets across clusters.
  2. Elliptic Curve (ES256) is 1.8x faster than RSA-2048 and produces a 40% smaller token payload, making it the preferred algorithm for modern API backends.

8. Failure Modes, Edge Cases & Bottlenecks

1. JWKS Endpoint Outage

If the Identity Provider's /.well-known/jwks.json endpoint experiences an outage and microservice caches expire, API requests will fail with HTTP 500 errors.

  • Mitigation: Configure AutomaticRefreshInterval = TimeSpan.FromHours(12) and RefreshInterval = TimeSpan.FromMinutes(5). Retain the stale public key cache if the JWKS endpoint returns an HTTP 5xx error.

2. Client Refresh Token Race Conditions

In single-page applications (SPAs), multiple concurrent AJAX requests encountering a 401 response might all attempt to exchange the single-use refresh token simultaneously.

  • Mitigation: Introduce a 15-second grace window on the Auth Server: if an already-used refresh token is presented within 15 seconds by the same IP and user agent, return the previously generated new access token instead of triggering a breach invalidation.

3. JTI Revocation Cache Floods

In high-volume consumer systems with millions of daily active users, caching every revoked token can consume excessive memory.

  • Mitigation: Revoke user tokens by updating a UserSecurityTimestamp claim in a central user store, invalidating all tokens issued before that timestamp rather than tracking individual token GUIDs.

9. Security, Isolation & Reliability Controls

  • Algorithm Whitelisting: Explicitly reject tokens with alg: "none" or unexpected algorithms by setting ValidAlgorithms = [SecurityAlgorithms.RsaSha256, SecurityAlgorithms.EcdsaSha256].
  • Tenant Scope Enforcement: Require both tenant_id and sub claims on every non-public endpoint.
  • TLS 1.3 Mandatory: Public JWKS endpoints must strictly reject plaintext HTTP traffic.

10. Architectural Trade-offs Matrix

Approach Advantages Disadvantages Verdict
Symmetric Static Secrets Fastest verification (0.08ms); simple configuration. Shared secret sprawl; single breach compromises entire system; no rotation. Strictly prohibited in enterprise production.
Asymmetric JWKS (Adopted) No secrets in microservices; automatic key rotation; industry standard. Slightly higher verification latency (0.3-0.6ms); requires auth provider. Production Standard for modern .NET APIs.
Database Sessions (Stateful) Instant revocation; easy to inspect active sessions. High DB load on every request; difficult to scale globally. Rejected for high-throughput microservices.

11. Scale Evolution (10x and 100x Growth Vectors)

  • At 10x Scale (50 Microservices, 50,000 RPS): Deploy API Gateway edge token validation (via Azure API Management or YARP). Validate JWTs and authorization scopes once at the perimeter gateway and pass lightweight verified caller claims internally via mutual TLS headers.
  • At 100x Scale (Multi-Region Global Perimeter): Implement distributed Redis Bloom Filters across all edge regions for sub-millisecond probabilistic token revocation checks, keeping memory consumption under 10MB for 10 million tokens.

12. Public GitHub Repository & Code Artifacts

The complete executable sample projects, JWKS mock servers, and integration tests are available in the engineering laboratory:

  • Repository: https://github.com/kdmbhushan/engineering-labs
  • Path: dotnet/jwt-authentication
  • Key Artifacts:
    • src/JwtAuth.Api/: Protected ASP.NET Core API with asymmetric JWKS validation.
    • src/JwtAuth.TokenServer/: Mock token issuer with key rotation and refresh token family tracking.
    • tests/JwtAuth.IntegrationTests/: End-to-end integration tests using WebApplicationFactory.

13. Related Canonical Publications


14. Multimedia & Video Walkthrough Outline

  1. The Secret Sprawl Vulnerability (0:00–2:30): Showing how leaking a symmetric key allows forging admin tokens with jwt.io.
  2. Asymmetric Key Pairs & JWKS (2:30–5:30): Explaining public/private key verification and configuring OpenIdConnectConfiguration.
  3. One-Time Refresh Token Rotation (5:30–8:30): Whiteboard animation showing token family invalidation upon theft detection.
  4. Implementing Token Revocation (8:30–11:00): Checking the jti claim against a Redis blocklist in OnTokenValidated.
  5. Key Takeaways & Production Sizing (11:00–13:00): Best practices for token lifespans, clock skew, and scopes.
BK

Bhushan Kadam

Principal Software Engineer • Dell Technologies

I am a Principal Software Engineer with 13+ years of experience designing distributed systems, cloud microservices, and applied AI agent workflows. Over my career, I have engineered production systems at Dell Technologies, JustAnswer, Allscripts, Wipro PARI Robotics, and Ignite Solutions.

Continue Reading