Learnixo
Back to blog
Backend Systemsadvanced

Zero Trust API Security in .NET: JWT, mTLS, and Defence in Depth

Secure .NET APIs with zero-trust principles. Covers JWT hardening, refresh token rotation, mTLS, API key management, rate limiting, threat modelling, OWASP API Top 10, and security testing.

LearnixoJune 4, 20267 min read
.NETC#SecurityZero TrustJWTmTLSOWASPASP.NET Core
Share:𝕏

Zero Trust Principles for APIs

"Never trust, always verify" — even internal services must authenticate. No implicit trust based on network location.

Applied to APIs:

  1. Every request carries credentials — no session, no implicit trust
  2. Credentials are short-lived and rotated
  3. Least privilege — tokens grant minimal necessary access
  4. All traffic is encrypted (even internal)
  5. Every request is logged for audit

JWT Hardening

Strict Validation

C#
builder.Services.AddAuthentication(JwtBearerDefaults.AuthenticationScheme)
    .AddJwtBearer(options =>
    {
        options.Authority    = builder.Configuration["Auth:Authority"];
        options.Audience     = builder.Configuration["Auth:Audience"];
        options.RequireHttpsMetadata = true;  // NEVER false in production

        options.TokenValidationParameters = new TokenValidationParameters
        {
            ValidateIssuer           = true,
            ValidateAudience         = true,
            ValidateLifetime         = true,
            ValidateIssuerSigningKey = true,
            ClockSkew                = TimeSpan.FromSeconds(30), // tight: default is 5 min
            ValidAlgorithms          = ["RS256", "ES256"],       // reject weak algorithms
        };

        // Reject tokens without expiry
        options.TokenValidationParameters.RequireExpirationTime = true;

        options.Events = new JwtBearerEvents
        {
            OnTokenValidated = ctx =>
            {
                // Additional custom validation
                var jti = ctx.Principal?.FindFirst(JwtRegisteredClaimNames.Jti)?.Value;
                if (string.IsNullOrEmpty(jti))
                {
                    ctx.Fail("Token missing jti claim");
                }
                return Task.CompletedTask;
            }
        };
    });

Token Claims Validation

C#
// Validate specific claims in a policy
builder.Services.AddAuthorization(options =>
{
    options.AddPolicy("RequireVerifiedEmail", policy =>
        policy.RequireClaim("email_verified", "true"));

    options.AddPolicy("ApiAccess", policy =>
        policy.RequireClaim("scope", "api:read", "api:write"));

    options.AddPolicy("AdminOnly", policy =>
        policy.RequireRole("admin")
              .RequireClaim("mfa_completed", "true")); // require MFA
});

Refresh Token Rotation

C#
public class TokenService : ITokenService
{
    private readonly IRefreshTokenRepository _repo;
    private readonly IConfiguration _config;

    public async Task<TokenPair> RefreshAsync(string refreshToken, CancellationToken ct)
    {
        var stored = await _repo.GetByTokenAsync(refreshToken, ct)
            ?? throw new SecurityException("Invalid refresh token.");

        if (stored.ExpiresAt < DateTime.UtcNow)
        {
            await _repo.RevokeAllForUserAsync(stored.UserId, ct); // revoke all on expired
            throw new SecurityException("Refresh token expired.");
        }

        if (stored.IsUsed)
        {
            // Token reuse detected — possible theft
            await _repo.RevokeAllForUserAsync(stored.UserId, ct);
            throw new SecurityException("Refresh token already used — possible token theft.");
        }

        // Rotate: mark old token as used, issue new pair
        await _repo.MarkUsedAsync(stored.Id, ct);

        var newAccessToken  = GenerateAccessToken(stored.UserId, stored.Claims);
        var newRefreshToken = await _repo.CreateAsync(stored.UserId, stored.Claims, ct);

        return new TokenPair(newAccessToken, newRefreshToken);
    }

    private string GenerateAccessToken(string userId, IEnumerable<Claim> claims)
    {
        var key     = new RsaSecurityKey(LoadPrivateKey());
        var creds   = new SigningCredentials(key, SecurityAlgorithms.RsaSha256);
        var expires = DateTime.UtcNow.AddMinutes(15); // short-lived

        var allClaims = claims.Concat(new[]
        {
            new Claim(JwtRegisteredClaimNames.Sub, userId),
            new Claim(JwtRegisteredClaimNames.Jti, Guid.NewGuid().ToString()),
            new Claim(JwtRegisteredClaimNames.Iat,
                DateTimeOffset.UtcNow.ToUnixTimeSeconds().ToString())
        });

        var token = new JwtSecurityToken(
            issuer:   _config["Auth:Issuer"],
            audience: _config["Auth:Audience"],
            claims:   allClaims,
            expires:  expires,
            signingCredentials: creds);

        return new JwtSecurityTokenHandler().WriteToken(token);
    }
}

API Keys (Service-to-Service)

C#
// API key authentication handler
public class ApiKeyAuthHandler : AuthenticationHandler<ApiKeyAuthOptions>
{
    private readonly IApiKeyValidator _validator;

    protected override async Task<AuthenticateResult> HandleAuthenticateAsync()
    {
        if (!Request.Headers.TryGetValue("X-Api-Key", out var apiKeyValue))
            return AuthenticateResult.NoResult();

        var apiKey = apiKeyValue.ToString();
        var principal = await _validator.ValidateAsync(apiKey, Context.RequestAborted);

        if (principal is null)
            return AuthenticateResult.Fail("Invalid API key.");

        return AuthenticateResult.Success(
            new AuthenticationTicket(principal, ApiKeyAuthOptions.Scheme));
    }
}

// API key validator — hash comparison, no plain text storage
public class ApiKeyValidator : IApiKeyValidator
{
    private readonly AppDbContext _db;

    public async Task<ClaimsPrincipal?> ValidateAsync(string rawKey, CancellationToken ct)
    {
        // Hash the incoming key to compare with stored hash
        var hash = ComputeHash(rawKey);

        var key = await _db.ApiKeys
            .Where(k => k.KeyHash == hash && k.IsActive && k.ExpiresAt > DateTime.UtcNow)
            .FirstOrDefaultAsync(ct);

        if (key is null) return null;

        // Update last used timestamp (don't block on this)
        _ = _db.ApiKeys
            .Where(k => k.Id == key.Id)
            .ExecuteUpdateAsync(s => s.SetProperty(k => k.LastUsedAt, DateTime.UtcNow), ct);

        return BuildPrincipal(key);
    }

    private static string ComputeHash(string key)
    {
        var bytes = SHA256.HashData(Encoding.UTF8.GetBytes(key));
        return Convert.ToHexString(bytes);
    }
}

mTLS (Mutual TLS) for Service-to-Service

C#
// Configure Kestrel to require client certificates
builder.WebHost.ConfigureKestrel(options =>
{
    options.ConfigureHttpsDefaults(https =>
    {
        https.ClientCertificateMode = ClientCertificateMode.RequireCertificate;
        https.ClientCertificateValidation = (cert, chain, errors) =>
        {
            // Validate the client cert thumbprint or issuer
            return cert.Thumbprint == AllowedCertThumbprint;
        };
    });
});

// Read the client certificate in a controller
[HttpGet("secure")]
public IActionResult SecureEndpoint()
{
    var cert = HttpContext.Connection.ClientCertificate;
    var clientId = cert?.Subject;
    // verify clientId is authorised for this operation
}

Rate Limiting and Abuse Prevention

C#
builder.Services.AddRateLimiter(options =>
{
    // Per-IP sliding window
    options.AddSlidingWindowLimiter("ip-limit", opt =>
    {
        opt.PermitLimit        = 100;
        opt.Window             = TimeSpan.FromMinutes(1);
        opt.SegmentsPerWindow  = 6;
        opt.QueueProcessingOrder = QueueProcessingOrder.OldestFirst;
    });

    // Per-user token bucket (burst-tolerant)
    options.AddTokenBucketLimiter("user-limit", opt =>
    {
        opt.TokenLimit            = 20;
        opt.ReplenishmentPeriod   = TimeSpan.FromSeconds(10);
        opt.TokensPerPeriod       = 5;
        opt.AutoReplenishment     = true;
    });

    // Partition by user or IP
    options.AddPolicy("authenticated", ctx =>
    {
        var userId = ctx.User.FindFirst(ClaimTypes.NameIdentifier)?.Value
                  ?? ctx.Connection.RemoteIpAddress?.ToString()
                  ?? "anonymous";

        return RateLimitPartition.GetSlidingWindowLimiter(userId, _ => new SlidingWindowRateLimiterOptions
        {
            PermitLimit       = 200,
            Window            = TimeSpan.FromMinutes(1),
            SegmentsPerWindow = 6
        });
    });

    options.OnRejected = async (context, ct) =>
    {
        context.HttpContext.Response.StatusCode = 429;
        context.HttpContext.Response.Headers.RetryAfter =
            context.Lease.TryGetMetadata(MetadataName.RetryAfter, out var retryAfter)
                ? ((int)retryAfter.TotalSeconds).ToString()
                : "60";
    };
});

app.UseRateLimiter();

OWASP API Top 10 Mitigations

| Risk | Mitigation in .NET | |---|---| | Broken Object Level Auth | Always filter by userId — never trust client-provided IDs without ownership check | | Broken Authentication | Short-lived JWTs, refresh token rotation, MFA | | Broken Object Property Level Auth | Use DTOs, never return full domain objects — control what properties are exposed | | Unrestricted Resource Consumption | Rate limiting, max page size, request body size limits | | Broken Function Level Auth | Policy-based auth on every endpoint, not just controllers | | Unrestricted Access to Sensitive Flows | Rate-limit auth endpoints, CAPTCHA on login | | Server-Side Request Forgery | Validate and allowlist URLs before making outbound requests | | Security Misconfiguration | Remove default pages, disable detailed errors in prod, HSTS | | Improper Inventory Management | OpenAPI docs, version all APIs, deprecate properly | | Unsafe Consumption of APIs | Validate and sanitise all third-party API responses |

C#
// Always check ownership — BOLA fix
[HttpGet("orders/{id}")]
public async Task<IActionResult> GetOrder(Guid id, CancellationToken ct)
{
    var currentUserId = User.GetUserId();
    var order = await _orders.GetByIdAsync(id, ct);

    if (order is null) return NotFound();

    // CRITICAL: verify the requesting user owns this order
    if (order.CustomerId != currentUserId)
        return Forbid(); // 403, not 404 — avoid revealing resource existence

    return Ok(MapToDto(order));
}

Security Headers

C#
app.Use(async (ctx, next) =>
{
    ctx.Response.Headers["X-Content-Type-Options"]    = "nosniff";
    ctx.Response.Headers["X-Frame-Options"]           = "DENY";
    ctx.Response.Headers["X-XSS-Protection"]          = "1; mode=block";
    ctx.Response.Headers["Referrer-Policy"]           = "strict-origin-when-cross-origin";
    ctx.Response.Headers["Permissions-Policy"]        = "geolocation=(), microphone=()";
    ctx.Response.Headers["Strict-Transport-Security"] = "max-age=31536000; includeSubDomains";
    await next(ctx);
});

Interview Questions

Q: What is the BOLA vulnerability and how do you prevent it? Broken Object Level Authorization — an API returns a resource based on an ID in the URL without verifying the requesting user owns it. Fix: always fetch the resource and check resource.ownerId == currentUserId before returning. Return 403 (not 404) on ownership failure to avoid revealing resource existence.

Q: Why should JWTs be short-lived (15 minutes)? A stolen JWT is valid until expiry. With a 24-hour token, an attacker has a 24-hour window. With a 15-minute token, the window is 15 minutes. Short-lived access tokens + longer-lived refresh tokens (with rotation and revocation) give you both security and good UX.

Q: What is refresh token rotation and why detect reuse? Each use of a refresh token issues a new token and invalidates the old one. If the old token is used again (reuse detected), it indicates token theft — someone has both the legitimate and stolen token. The secure response is to revoke all tokens for that user, forcing re-authentication.

Q: What is mTLS and when would you use it for .NET APIs? Mutual TLS — both the server and client present certificates. The server authenticates the client's certificate. Used for internal service-to-service communication where you want cryptographic proof of service identity beyond just a shared secret. Kubernetes environments with a service mesh (Istio, Linkerd) can handle mTLS transparently.

Enjoyed this article?

Explore the Backend Systems learning path for more.

Found this helpful?

Share:𝕏

Leave a comment

Have a question, correction, or just found this helpful? Leave a note below.