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.
Zero Trust Principles for APIs
"Never trust, always verify" — even internal services must authenticate. No implicit trust based on network location.
Applied to APIs:
- Every request carries credentials — no session, no implicit trust
- Credentials are short-lived and rotated
- Least privilege — tokens grant minimal necessary access
- All traffic is encrypted (even internal)
- Every request is logged for audit
JWT Hardening
Strict Validation
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
// 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
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)
// 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
// 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
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 |
// 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
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?
Leave a comment
Have a question, correction, or just found this helpful? Leave a note below.