Security Best Practices¶
Verified by tests
MessageSecurityContextProviderTests, SecurityIntegrationTests, WhizbangSecurityHeadersMiddlewareTests — library CI run #31657041675 (2026-08-13)
Comprehensive security guide for Whizbang applications - authentication, authorization, data encryption, secrets management, and OWASP Top 10 mitigations.
Security Checklist¶
| Category | Requirement | Status |
|---|---|---|
| Authentication | JWT with RS256 signing | ✅ |
| Authorization | Policy-based RBAC | ✅ |
| Encryption | TLS 1.3 in transit | ✅ |
| Encryption | AES-256 at rest | ✅ |
| Secrets | Azure Key Vault | ✅ |
| Input Validation | Command validation | ✅ |
| SQL Injection | Parameterized queries | ✅ |
| CSRF | SameSite cookies | ✅ |
Authentication¶
JWT with RS256¶
Why RS256 (asymmetric)?: - ✅ Public key verification (no shared secret) - ✅ Harder to compromise (private key stays on auth server) - ✅ Standard for microservices
appsettings.json:
JWT with RS256
{
"Authentication": {
"Authority": "https://login.microsoftonline.com/{tenant-id}/v2.0",
"Audience": "api://order-service",
"ValidIssuer": "https://login.microsoftonline.com/{tenant-id}/v2.0"
}
}
Program.cs:
JWT with RS256 (2)
builder.Services
.AddAuthentication(JwtBearerDefaults.AuthenticationScheme)
.AddJwtBearer(options => {
options.Authority = builder.Configuration["Authentication:Authority"];
options.Audience = builder.Configuration["Authentication:Audience"];
options.TokenValidationParameters = new TokenValidationParameters {
ValidateIssuer = true,
ValidateAudience = true,
ValidateLifetime = true,
ValidateIssuerSigningKey = true,
ValidIssuer = builder.Configuration["Authentication:ValidIssuer"],
ClockSkew = TimeSpan.Zero // No grace period for expired tokens
};
});
app.UseAuthentication();
app.UseAuthorization();
Require Authentication on Endpoints¶
Require Authentication on Endpoints
app.MapPost("/orders", async (
CreateOrderCommand command,
IDispatcher dispatcher
) => {
var result = await dispatcher.LocalInvokeAsync<CreateOrderCommand, OrderCreatedEvent>(command);
return Results.Created($"/orders/{result.OrderId}", result);
})
.RequireAuthorization(); // ✅ Require authentication
Authorization¶
Policy-Based Authorization¶
Program.cs:
Policy-Based Authorization
builder.Services.AddAuthorizationBuilder()
.AddPolicy("CreateOrder", policy => policy
.RequireAuthenticatedUser()
.RequireClaim("scope", "orders.write"))
.AddPolicy("ViewOrders", policy => policy
.RequireAuthenticatedUser()
.RequireClaim("scope", "orders.read"))
.AddPolicy("AdminOnly", policy => policy
.RequireAuthenticatedUser()
.RequireRole("Admin"));
Usage:
Policy-Based Authorization (2)
app.MapPost("/orders", async (
CreateOrderCommand command,
IDispatcher dispatcher
) => {
var result = await dispatcher.LocalInvokeAsync<CreateOrderCommand, OrderCreatedEvent>(command);
return Results.Created($"/orders/{result.OrderId}", result);
})
.RequireAuthorization("CreateOrder"); // ✅ Require specific policy
app.MapGet("/orders/{orderId}", async (
string orderId,
IDbConnection db
) => {
var order = await db.QuerySingleOrDefaultAsync<OrderRow>(
"SELECT * FROM orders WHERE order_id = @OrderId",
new { OrderId = orderId }
);
return order is not null ? Results.Ok(order) : Results.NotFound();
})
.RequireAuthorization("ViewOrders");
Resource-Based Authorization¶
OrderAuthorizationHandler.cs:
Resource-Based Authorization
public class OrderAuthorizationHandler : AuthorizationHandler<OperationAuthorizationRequirement, OrderRow> {
protected override Task HandleRequirementAsync(
AuthorizationHandlerContext context,
OperationAuthorizationRequirement requirement,
OrderRow order
) {
var userId = context.User.FindFirst(ClaimTypes.NameIdentifier)?.Value;
// Users can only view their own orders (unless admin)
if (requirement.Name == "View") {
if (context.User.IsInRole("Admin") || order.CustomerId == userId) {
context.Succeed(requirement);
}
}
// Only admins can delete orders
if (requirement.Name == "Delete") {
if (context.User.IsInRole("Admin")) {
context.Succeed(requirement);
}
}
return Task.CompletedTask;
}
}
Registration:
Resource-Based Authorization (2)
Usage:
Resource-Based Authorization (3)
app.MapDelete("/orders/{orderId}", async (
string orderId,
IDbConnection db,
IAuthorizationService authz,
HttpContext context
) => {
var order = await db.QuerySingleOrDefaultAsync<OrderRow>(
"SELECT * FROM orders WHERE order_id = @OrderId",
new { OrderId = orderId }
);
if (order is null) {
return Results.NotFound();
}
// Check authorization
var authResult = await authz.AuthorizeAsync(
context.User,
order,
new OperationAuthorizationRequirement { Name = "Delete" }
);
if (!authResult.Succeeded) {
return Results.Forbid();
}
await db.ExecuteAsync(
"DELETE FROM orders WHERE order_id = @OrderId",
new { OrderId = orderId }
);
return Results.NoContent();
})
.RequireAuthorization();
Whizbang Message Security¶
HTTP-level authorization covers your endpoints; message-level security covers everything flowing through the dispatcher and transports. Whizbang ships this as a first-class subsystem:
Whizbang Message Security
// Program.cs - message security pipeline
builder.Services.AddWhizbangMessageSecurity(options => {
options.AllowAnonymous = false; // default: reject messages without a security context
options.EnableAuditLogging = true; // default: true
options.ValidateCredentials = true; // default: true
options.PropagateToOutgoingMessages = true; // default: true - context flows to cascaded/outgoing messages
options.ExemptMessageTypes.Add(typeof(HealthPingCommand)); // opt specific messages out
});
// Receptor-level permission gate - enforced by the receptor interceptor pipeline
[RequirePermission("orders.write")]
public class CreateOrderReceptor(IDispatcher dispatcher, ILogger<CreateOrderReceptor> logger)
: IReceptor<CreateOrderCommand, OrderCreatedEvent> {
// ...
}
The security context captured at the edge (e.g., from the JWT) travels with the message across the outbox, transport, and inbox - receptors on other services see the same principal. Row-level scoping ([Scoped]) and column-level masking ([FieldPermission]) build on the same context; see the Security fundamentals page for the full model.
Encryption¶
TLS 1.3 (In Transit)¶
appsettings.json:
TLS 1.3 (In Transit)
{
"Kestrel": {
"Endpoints": {
"Https": {
"Url": "https://0.0.0.0:443",
"Certificate": {
"Path": "/app/certs/certificate.pfx",
"Password": "***"
},
"Protocols": "Http1AndHttp2AndHttp3",
"SslProtocols": ["Tls13"]
}
}
}
}
AES-256 Encryption (At Rest)¶
DataEncryptionService.cs:
AES-256 Encryption (At Rest)
public interface IDataEncryptionService {
byte[] Encrypt(byte[] plaintext);
byte[] Decrypt(byte[] ciphertext);
}
public class AesDataEncryptionService : IDataEncryptionService {
private readonly byte[] _key;
public AesDataEncryptionService(IConfiguration config) {
// Get encryption key from Azure Key Vault
_key = Convert.FromBase64String(config["Encryption:Key"]);
if (_key.Length != 32) {
throw new InvalidOperationException("Encryption key must be 256 bits (32 bytes)");
}
}
public byte[] Encrypt(byte[] plaintext) {
using var aes = Aes.Create();
aes.Key = _key;
aes.GenerateIV(); // Random IV for each encryption
using var encryptor = aes.CreateEncryptor();
using var ms = new MemoryStream();
// Write IV first (needed for decryption)
ms.Write(aes.IV, 0, aes.IV.Length);
using (var cs = new CryptoStream(ms, encryptor, CryptoStreamMode.Write)) {
cs.Write(plaintext, 0, plaintext.Length);
}
return ms.ToArray();
}
public byte[] Decrypt(byte[] ciphertext) {
using var aes = Aes.Create();
aes.Key = _key;
// Read IV from ciphertext
var iv = new byte[16];
Array.Copy(ciphertext, 0, iv, 0, 16);
aes.IV = iv;
using var decryptor = aes.CreateDecryptor();
using var ms = new MemoryStream(ciphertext, 16, ciphertext.Length - 16);
using var cs = new CryptoStream(ms, decryptor, CryptoStreamMode.Read);
using var result = new MemoryStream();
cs.CopyTo(result);
return result.ToArray();
}
}
Usage:
AES-256 Encryption (At Rest) (2)
public async ValueTask<PaymentProcessedEvent> HandleAsync(
ProcessPaymentCommand command,
CancellationToken cancellationToken = default
) {
// Encrypt sensitive data before storing
var encryptedCardNumber = _encryption.Encrypt(
Encoding.UTF8.GetBytes(command.CardNumber)
);
await _db.ExecuteAsync(
"""
INSERT INTO payments (payment_id, order_id, encrypted_card_number, created_at)
VALUES (@PaymentId, @OrderId, @EncryptedCardNumber, NOW())
""",
new {
PaymentId = paymentId,
OrderId = command.OrderId,
EncryptedCardNumber = encryptedCardNumber
}
);
return new PaymentProcessedEvent { PaymentId = paymentId };
}
Secrets Management¶
Azure Key Vault¶
Program.cs:
Azure Key Vault
var keyVaultUri = new Uri(builder.Configuration["KeyVault:VaultUri"]);
builder.Configuration.AddAzureKeyVault(
keyVaultUri,
new DefaultAzureCredential()
);
Azure Key Vault Secrets:
Azure Key Vault (2)
# Create secrets in Key Vault
az keyvault secret set \
--vault-name whizbang-kv \
--name "Database--ConnectionString" \
--value "Host=...;Database=orders;Username=app;Password=***"
az keyvault secret set \
--vault-name whizbang-kv \
--name "AzureServiceBus--ConnectionString" \
--value "Endpoint=sb://...;SharedAccessKeyName=...;SharedAccessKey=***"
az keyvault secret set \
--vault-name whizbang-kv \
--name "Encryption--Key" \
--value "base64-encoded-256-bit-key"
Usage:
Azure Key Vault (3)
// Automatically resolved from Key Vault
var connectionString = builder.Configuration["Database:ConnectionString"];
var serviceBusConnectionString = builder.Configuration["AzureServiceBus:ConnectionString"];
var encryptionKey = builder.Configuration["Encryption:Key"];
Managed Identity (Avoid Credentials)¶
appsettings.json:
Managed Identity (Avoid Credentials)
No credentials needed - Azure Managed Identity provides access:
Managed Identity (Avoid Credentials) (2)
# Assign Managed Identity to App Service
az webapp identity assign --name whizbang-api --resource-group whizbang-rg
# Grant Key Vault access to Managed Identity
az keyvault set-policy \
--name whizbang-kv \
--object-id <managed-identity-object-id> \
--secret-permissions get list
Input Validation¶
Command Validation¶
CreateOrderValidator.cs:
Command Validation
public static class CreateOrderValidator {
public static ValidationResult Validate(CreateOrderCommand command) {
var errors = new List<string>();
if (command.LineItems.Count == 0) {
errors.Add("Order must contain at least one item");
}
foreach (var item in command.LineItems) {
if (item.Quantity <= 0) {
errors.Add($"Item {item.ProductId}: Quantity must be greater than zero");
}
if (item.UnitPrice <= 0) {
errors.Add($"Item {item.ProductId}: Unit price must be greater than zero");
}
}
return errors.Count == 0
? ValidationResult.Success()
: ValidationResult.Failure(errors);
}
}
Enforce in the receptor (the pattern used by the ECommerce sample - validation runs before any event is published):
Command Validation - Receptor Guard
public class CreateOrderReceptor(IDispatcher dispatcher, ILogger<CreateOrderReceptor> logger)
: IReceptor<CreateOrderCommand, OrderCreatedEvent> {
public async ValueTask<OrderCreatedEvent> HandleAsync(
CreateOrderCommand command,
CancellationToken cancellationToken = default) {
var result = CreateOrderValidator.Validate(command);
if (!result.IsSuccess) {
throw new ValidationException(string.Join("; ", result.Errors));
}
// ... publish OrderCreatedEvent only after validation passes
}
}
For validation that must run for every message of a type regardless of which receptor handles it, register a lifecycle receptor with [FireAt(LifecycleStage.PreOutboxInline)] (sender side) or [FireAt(LifecycleStage.PreInboxInline)] (receiver side) - inline stages block the pipeline until the guard completes.
SQL Injection Prevention¶
✅ ALWAYS use parameterized queries:
SQL Injection Prevention
// ✅ GOOD - Parameterized query (safe)
var orders = await _db.QueryAsync<OrderRow>(
"""
SELECT * FROM orders
WHERE customer_id = @CustomerId AND created_at >= @StartDate
""",
new { CustomerId = customerId, StartDate = startDate }
);
// ❌ BAD - String interpolation (SQL injection risk)
var orders = await _db.QueryAsync<OrderRow>(
$"SELECT * FROM orders WHERE customer_id = '{customerId}'"
);
OWASP Top 10 Mitigations¶
1. Broken Access Control¶
✅ Mitigation: Policy-based authorization + resource-based authorization
Broken Access Control
// Check user can access resource
var authResult = await _authz.AuthorizeAsync(user, order, "View");
if (!authResult.Succeeded) {
return Results.Forbid();
}
2. Cryptographic Failures¶
✅ Mitigation: TLS 1.3 + AES-256 encryption + Azure Key Vault
Cryptographic Failures
3. Injection¶
✅ Mitigation: Parameterized queries + input validation
Injection
// Always use parameters
await _db.ExecuteAsync(
"INSERT INTO orders (...) VALUES (@Value)",
new { Value = userInput }
);
4. Insecure Design¶
✅ Mitigation: Principle of least privilege + defense in depth
Insecure Design
// Multiple layers of security
app.MapPost("/orders", CreateOrderEndpoint)
.RequireAuthorization("CreateOrder"); // Layer 1: HTTP policy
builder.Services.AddWhizbangMessageSecurity(); // Layer 2: message security context
[RequirePermission("orders.write")] // Layer 3: receptor permission gate
public class CreateOrderReceptor : IReceptor<CreateOrderCommand, OrderCreatedEvent> {
// Layer 4: command validation inside HandleAsync
}
5. Security Misconfiguration¶
✅ Mitigation: Secure defaults + configuration validation
Security Misconfiguration
// Validate configuration on startup
var requiredSettings = new[] {
"Database:ConnectionString",
"AzureServiceBus:ConnectionString",
"Encryption:Key"
};
foreach (var setting in requiredSettings) {
if (string.IsNullOrEmpty(builder.Configuration[setting])) {
throw new InvalidOperationException($"Missing required setting: {setting}");
}
}
6. Vulnerable and Outdated Components¶
✅ Mitigation: Automated dependency scanning
Vulnerable and Outdated Components
# .github/workflows/security-scan.yml
name: Security Scan
on:
push:
branches: [main]
schedule:
- cron: '0 0 * * 0' # Weekly
jobs:
scan:
runs-on: ubuntu-latest
steps:
- uses: actions/checkout@v4
- name: Run Trivy vulnerability scanner
uses: aquasecurity/trivy-action@master
with:
scan-type: 'fs'
scan-ref: '.'
format: 'sarif'
output: 'trivy-results.sarif'
- name: Upload results to GitHub Security
uses: github/codeql-action/upload-sarif@v2
with:
sarif_file: 'trivy-results.sarif'
7. Identification and Authentication Failures¶
✅ Mitigation: JWT with short expiry + refresh tokens
Identification and Authentication Failures
options.TokenValidationParameters = new TokenValidationParameters {
ValidateLifetime = true,
ClockSkew = TimeSpan.Zero // No grace period
};
8. Software and Data Integrity Failures¶
✅ Mitigation: Envelope identity + inbox deduplication + authenticated transports
Every Whizbang message travels in a MessageEnvelope with a unique MessageId and a per-service hop chain (CorrelationId / CausationId), and the inbox deduplicates on message identity - a replayed or duplicated message is rejected before your receptors run (whizbang.dispatcher.duplicates_detected counts these). Combine that with:
- Authenticated transports - Azure Service Bus (AAD / SAS) and RabbitMQ credentials authenticate every publish and consume; TLS protects the payload in transit.
- Credential validation on messages -
MessageSecurityOptions.ValidateCredentials = true(default) rejects messages whose security context fails validation. - Supply-chain integrity - lock files + signed packages for the software half of this OWASP category.
9. Security Logging and Monitoring Failures¶
✅ Mitigation: Structured logging + Application Insights
Security Logging and Monitoring Failures
_logger.LogWarning(
"Unauthorized access attempt: User {UserId} attempted to access Order {OrderId}",
userId,
orderId
);
10. Server-Side Request Forgery (SSRF)¶
✅ Mitigation: Whitelist allowed hosts + URL validation
Server-Side Request Forgery (SSRF)
// Guard any receptor that makes outbound calls from message-supplied URLs
public static class OutboundUrlGuard {
private static readonly string[] AllowedHosts = [
"api.stripe.com",
"api.twilio.com"
];
public static void EnsureAllowed(string url) {
var uri = new Uri(url);
if (uri.Scheme != Uri.UriSchemeHttps || !AllowedHosts.Contains(uri.Host)) {
throw new SecurityException($"Host not allowed: {uri.Host}");
}
}
}
// In the receptor, before calling out:
OutboundUrlGuard.EnsureAllowed(command.CallbackUrl);
Security Headers¶
Whizbang ships a security-headers middleware in Whizbang.Hosting.AspNet - opt in with one line instead of hand-rolling:
Security Headers
// Defaults: Strict-Transport-Security (1y, includeSubDomains, preload),
// X-Content-Type-Options: nosniff, X-Frame-Options: DENY,
// CSP: frame-ancestors 'none', Referrer-Policy: strict-origin-when-cross-origin,
// Permissions-Policy: camera=(), microphone=(), geolocation=()
app.UseWhizbangSecurityHeaders();
// Or customize (set a header value to null to suppress it):
app.UseWhizbangSecurityHeaders(options => {
options.ContentSecurityPolicy = "default-src 'self'; script-src 'self'; style-src 'self'";
options.XFrameOptions = "SAMEORIGIN";
});
Headers are only added when absent, so app-specific values you set elsewhere win.
Rate Limiting¶
Program.cs:
Rate Limiting
builder.Services.AddRateLimiter(options => {
options.AddFixedWindowLimiter("api", limiter => {
limiter.PermitLimit = 100;
limiter.Window = TimeSpan.FromMinutes(1);
limiter.QueueProcessingOrder = QueueProcessingOrder.OldestFirst;
limiter.QueueLimit = 10;
});
});
app.UseRateLimiter();
Usage:
Rate Limiting (2)
app.MapPost("/orders", async (
CreateOrderCommand command,
IDispatcher dispatcher
) => {
var result = await dispatcher.LocalInvokeAsync<CreateOrderCommand, OrderCreatedEvent>(command);
return Results.Created($"/orders/{result.OrderId}", result);
})
.RequireAuthorization()
.RequireRateLimiting("api");
Key Takeaways¶
✅ JWT with RS256 - Asymmetric signing for microservices ✅ Policy-Based Authorization - Fine-grained access control ✅ TLS 1.3 + AES-256 - Encryption in transit and at rest ✅ Azure Key Vault - Centralized secrets management ✅ Input Validation - Validate all commands ✅ Parameterized Queries - Prevent SQL injection ✅ OWASP Top 10 - Comprehensive mitigations ✅ Rate Limiting - Prevent abuse
Version 1.0.0 - Foundation Release | Last Updated: 2024-12-12