Building Defense in Depth
If you've ever watched a security breach unfold because one weak point compromised an entire system, you understand why relying on a single security layer is dangerous. An attacker who bypasses authentication shouldn't automatically access all data. A vulnerability in one component shouldn't expose everything else. Multi-layered security creates redundant defenses where each layer reduces risk independently.
Modern .NET applications need authentication to verify identity, authorization to control access, data protection to secure sensitive information, and secure coding practices to prevent injection attacks. Each layer catches threats the others might miss. When combined correctly, they create a robust defense that's far harder to penetrate than any single mechanism.
You'll build examples showing authentication with JWT tokens, authorization policies based on claims, encryption for sensitive data, and input validation that stops injection attacks. These patterns integrate seamlessly with ASP.NET Core and work together to protect your applications.
Layer 1: Authentication
Authentication proves who users are before granting access. ASP.NET Core supports multiple schemes including cookies, JWT tokens, and OAuth/OpenID Connect. JWT tokens work well for APIs because they're stateless and can be validated without database calls.
The authentication middleware runs early in the pipeline, examining incoming requests for credentials. If credentials are valid, it creates a ClaimsPrincipal representing the user's identity. Subsequent middleware and controllers access this principal to make authorization decisions.
using Microsoft.AspNetCore.Authentication.JwtBearer;
using Microsoft.IdentityModel.Tokens;
using System.Text;
var builder = WebApplication.CreateBuilder(args);
var jwtKey = builder.Configuration["Jwt:Key"];
var jwtIssuer = builder.Configuration["Jwt:Issuer"];
builder.Services.AddAuthentication(JwtBearerDefaults.AuthenticationScheme)
.AddJwtBearer(options =>
{
options.TokenValidationParameters = new TokenValidationParameters
{
ValidateIssuer = true,
ValidateAudience = true,
ValidateLifetime = true,
ValidateIssuerSigningKey = true,
ValidIssuer = jwtIssuer,
ValidAudience = jwtIssuer,
IssuerSigningKey = new SymmetricSecurityKey(
Encoding.UTF8.GetBytes(jwtKey))
};
});
builder.Services.AddAuthorization();
var app = builder.Build();
app.UseAuthentication();
app.UseAuthorization();
app.MapGet("/secure", () => "This endpoint requires authentication")
.RequireAuthorization();
app.Run();
The authentication middleware validates JWT tokens on every request. Valid tokens create an authenticated user context. Invalid or missing tokens result in 401 Unauthorized responses. This ensures only legitimate users proceed past this layer.
Layer 2: Authorization
Authentication answers "who are you?" while authorization answers "what can you do?" Even authenticated users shouldn't access everything. Role-based and policy-based authorization control what each user can do based on their identity claims.
Claims represent facts about a user like their username, roles, or department. Authorization policies evaluate these claims to grant or deny access. This decouples permission logic from your controllers, making security rules consistent and testable.
using Microsoft.AspNetCore.Authorization;
using System.Security.Claims;
var builder = WebApplication.CreateBuilder(args);
builder.Services.AddAuthorization(options =>
{
options.AddPolicy("AdminOnly", policy =>
policy.RequireClaim(ClaimTypes.Role, "Admin"));
options.AddPolicy("Over18", policy =>
policy.RequireAssertion(context =>
{
var ageClaim = context.User.FindFirst("Age");
return ageClaim != null && int.Parse(ageClaim.Value) >= 18;
}));
options.AddPolicy("CanDeleteOrders", policy =>
policy.RequireAssertion(context =>
context.User.HasClaim(ClaimTypes.Role, "Admin") ||
context.User.HasClaim("Department", "Sales")));
});
var app = builder.Build();
app.MapGet("/admin", () => "Admin access granted")
.RequireAuthorization("AdminOnly");
app.MapDelete("/orders/{id}", (int id) => $"Order {id} deleted")
.RequireAuthorization("CanDeleteOrders");
app.Run();
Policies centralize authorization logic. Controllers apply policies by name using RequireAuthorization or the Authorize attribute. When you need to change who can delete orders, you update one policy definition rather than hunting through multiple controllers.
Layer 3: Data Protection
Encrypting sensitive data protects it even if attackers gain database access. ASP.NET Core's Data Protection API provides easy encryption for cookies, tokens, and application data. It handles key management, rotation, and secure storage automatically.
For passwords, always use purpose-built hashing algorithms like BCrypt or Argon2, never general-purpose hashes like SHA-256. ASP.NET Core Identity includes PasswordHasher that uses PBKDF2 by default, providing adequate protection with proper salt and iteration count.
using Microsoft.AspNetCore.DataProtection;
public class SensitiveDataService
{
private readonly IDataProtector _protector;
public SensitiveDataService(IDataProtectionProvider provider)
{
_protector = provider.CreateProtector("SensitiveData.v1");
}
public string EncryptCreditCard(string cardNumber)
{
return _protector.Protect(cardNumber);
}
public string DecryptCreditCard(string encryptedCard)
{
try
{
return _protector.Unprotect(encryptedCard);
}
catch (CryptographicException)
{
// Decryption failed - tampered or wrong key
throw new SecurityException("Invalid encrypted data");
}
}
}
// Registration in Program.cs
builder.Services.AddDataProtection();
builder.Services.AddSingleton<SensitiveDataService>();
The Data Protection API creates machine-specific keys by default, stored in the user profile. For multi-server deployments, configure a shared key repository in Azure Key Vault or a database. The purpose string ensures data encrypted for one purpose can't be decrypted by another, preventing cryptographic confusion attacks.
Hands-On Exercise
Create a minimal API demonstrating authentication, authorization, and secure endpoints. This exercise combines JWT auth with policy-based authorization.
Steps
- Init a web API:
dotnet new webapi -n SecureApi
- Navigate:
cd SecureApi
- Replace Program.cs with the code below
- Update appsettings.json to include JWT configuration
- Start the server:
dotnet run
- Test with curl:
curl -X POST http://localhost:5000/login -H "Content-Type: application/json" -d '{"username":"admin","password":"secret"}'
<Project Sdk="Microsoft.NET.Sdk.Web">
<PropertyGroup>
<TargetFramework>net8.0</TargetFramework>
<Nullable>enable</Nullable>
<ImplicitUsings>enable</ImplicitUsings>
</PropertyGroup>
<ItemGroup>
<PackageReference Include="Microsoft.AspNetCore.Authentication.JwtBearer"
Version="8.0.0" />
</ItemGroup>
</Project>
using Microsoft.AspNetCore.Authentication.JwtBearer;
using Microsoft.IdentityModel.Tokens;
using System.IdentityModel.Tokens.Jwt;
using System.Security.Claims;
using System.Text;
var builder = WebApplication.CreateBuilder(args);
var key = "SuperSecretKeyThatIsAtLeast32CharactersLong!";
builder.Services.AddAuthentication(JwtBearerDefaults.AuthenticationScheme)
.AddJwtBearer(o => o.TokenValidationParameters = new()
{
ValidateIssuer = true,
ValidateAudience = true,
ValidIssuer = "SecureApi",
ValidAudience = "SecureApi",
IssuerSigningKey = new SymmetricSecurityKey(Encoding.UTF8.GetBytes(key))
});
builder.Services.AddAuthorization(o =>
o.AddPolicy("AdminOnly", p => p.RequireClaim(ClaimTypes.Role, "Admin")));
var app = builder.Build();
app.UseAuthentication();
app.UseAuthorization();
app.MapPost("/login", (LoginRequest req) =>
{
if (req.Username != "admin" || req.Password != "secret")
return Results.Unauthorized();
var claims = new[]
{
new Claim(ClaimTypes.Name, req.Username),
new Claim(ClaimTypes.Role, "Admin")
};
var token = new JwtSecurityToken(
issuer: "SecureApi",
audience: "SecureApi",
claims: claims,
expires: DateTime.UtcNow.AddHours(1),
signingCredentials: new SigningCredentials(
new SymmetricSecurityKey(Encoding.UTF8.GetBytes(key)),
SecurityAlgorithms.HmacSha256));
return Results.Ok(new { token = new JwtSecurityTokenHandler()
.WriteToken(token) });
});
app.MapGet("/public", () => "Public endpoint - no auth required");
app.MapGet("/secure", () => "Authenticated endpoint")
.RequireAuthorization();
app.MapGet("/admin", () => "Admin-only endpoint")
.RequireAuthorization("AdminOnly");
app.Run();
record LoginRequest(string Username, string Password);
Expected Result
The POST to /login returns a JWT token. Use it in subsequent requests: curl http://localhost:5000/admin -H "Authorization: Bearer YOUR_TOKEN". The /admin endpoint responds with success when authenticated as an admin.
Production Integration Notes
Deploying secure applications requires additional considerations beyond code. Store secrets in Azure Key Vault, AWS Secrets Manager, or environment variables, never in source control. Use the Secret Manager tool for local development to keep credentials outside your repository.
Enable HTTPS everywhere in production. Use Let's Encrypt for free certificates or your cloud provider's certificate service. Configure HSTS with a reasonable max-age and consider submitting your domain to the HSTS preload list for additional protection.
Implement rate limiting to prevent brute force attacks on login endpoints. ASP.NET Core 8 includes built-in rate limiting middleware that you can configure per endpoint. Combine it with account lockout policies to block attackers after repeated failed attempts.
Log security events like failed logins, authorization failures, and data access patterns. Use structured logging with correlation IDs to trace requests across services. Monitor these logs for suspicious patterns and set up alerts for anomalies. Security without observability is security you can't verify or improve.