Security Guide
This guide covers security hardening for Excalibur applications, including encryption, authentication, authorization, and audit logging.
Before You Start
- .NET 10.0
- Install the required packages:
dotnet add package Excalibur.Security
- Familiarity with middleware and encryption providers
Overview
Excalibur provides multiple security capabilities for building compliant, secure applications:
| Capability | Description | Standards |
|---|---|---|
| Encryption | Field-level AES-256-GCM encryption | SOC 2 CC6, GDPR Art. 32 |
| Authorization | Role-based access control | SOC 2 CC5 |
| Audit Logging | Tamper-evident hash chains | SOC 2 CC4 |
| Key Management | Cloud KMS integration | SOC 2 CC6.1 |
Encryption
Field-Level Encryption
Encrypt personal data with the [PersonalData] attribute. [Sensitive] is a classification and log-masking marker alongside it — it does not encrypt:
public class Customer
{
public Guid Id { get; set; }
public string Name { get; set; }
[PersonalData] // GDPR personal data - encrypted at rest
public string Email { get; set; }
[PersonalData]
[Sensitive] // Classification + masked in logs. Does NOT encrypt on its own.
public string SocialSecurityNumber { get; set; }
}
[Sensitive] and encryption: check the version you holdIn every published version up to and including 10.0.0-alpha.11, [Sensitive] classifies and masks
only — it does not cause encryption at rest. Confirm this against the package you restored rather
than against our source, which describes the main branch.
On the main branch [Sensitive] also selects a property for encryption at rest, under the configured
default key purpose. That change is not in any released version. When a release carries it, this
note will name that version; until it names one, do not assert encryption on the strength of
[Sensitive] alone.
Configuration
builder.Services.AddAzureKeyVaultKeyManagement(options =>
{
options.VaultUri = new Uri(configuration["KeyVault:Uri"]!);
// Uses DefaultAzureCredential by default (supports managed identity)
});
Encryption Providers
| Provider | Use Case | Configuration |
|---|---|---|
| Azure Key Vault | Production, Azure | AddAzureKeyVaultKeyManagement() |
| AWS KMS | Production, AWS | AddAwsKmsKeyManagement() |
| HashiCorp Vault | Multi-cloud | AddVaultKeyManagement() |
Transport Encryption
All network communication should use TLS 1.2 or higher:
builder.WebHost.ConfigureKestrel(options =>
{
options.ConfigureHttpsDefaults(https =>
{
https.SslProtocols = SslProtocols.Tls12 | SslProtocols.Tls13;
https.ClientCertificateMode = ClientCertificateMode.RequireCertificate;
});
});
Authorization
Excalibur uses interface-based authorization where messages declare their authorization requirements by implementing specific interfaces. The AuthorizationMiddleware automatically enforces these requirements during message processing.
Authorization Interfaces
| Interface | Purpose | Key Properties |
|---|---|---|
IRequireAuthorization | Base interface for all authorized messages | ActivityName |
IRequireRoleAuthorization | Role-based authorization | RequiredRoles |
IRequireActivityAuthorization | Activity/resource-based authorization | ResourceId, ResourceTypes |
IRequireCustomAuthorization | Custom authorization requirements | AuthorizationRequirements |
Basic Authorization
Implement IRequireAuthorization to require a specific activity permission:
using Excalibur.A3.Authorization;
public class DeleteUserCommand : IDispatchAction, IRequireAuthorization
{
public Guid UserId { get; set; }
// Required activity permission
public string ActivityName => "Users.Delete";
}
Role-Based Authorization
Implement IRequireRoleAuthorization to require specific roles:
public class AdminOnlyCommand : IDispatchAction, IRequireRoleAuthorization
{
public string ActivityName => "Admin.Execute";
// Require one or more roles
public IReadOnlyCollection<string>? RequiredRoles => new[] { "Admin", "SuperUser" };
}
Activity-Based Authorization (Resource-Specific)
Implement IRequireActivityAuthorization for resource-level permissions:
public class UpdateOrderCommand : IDispatchAction, IRequireActivityAuthorization
{
public Guid OrderId { get; set; }
public string ActivityName => "Orders.Update";
// Resource being accessed
public string? ResourceId => OrderId.ToString();
// Resource types for permission lookup
public string[] ResourceTypes => new[] { "Order" };
}
public class TransferFundsCommand : IDispatchAction, IRequireActivityAuthorization
{
public Guid FromAccountId { get; set; }
public Guid ToAccountId { get; set; }
public decimal Amount { get; set; }
public string ActivityName => Amount > 10000
? "Transfers.HighValue"
: "Transfers.Create";
public string? ResourceId => FromAccountId.ToString();
public string[] ResourceTypes => new[] { "Account" };
}
Custom Authorization Requirements
Implement IRequireCustomAuthorization for complex authorization scenarios:
using Microsoft.AspNetCore.Authorization;
public class ComplexCommand : IDispatchAction, IRequireCustomAuthorization
{
public string ActivityName => "Complex.Execute";
public IEnumerable<IAuthorizationRequirement> AuthorizationRequirements => new[]
{
new ClaimsAuthorizationRequirement("department", new[] { "Engineering" }),
new MinimumAgeRequirement(18)
};
}
Authorization Middleware
The AuthorizationMiddleware is built-in and automatically processes messages that implement IRequireAuthorization:
// Registration - middleware is automatically included with A3 services
builder.Services.AddExcaliburA3()
.UseSqlServer();
// Or register the middleware manually if needed
builder.Services.AddDispatch(options =>
{
options.AddMiddleware<AuthorizationMiddleware>();
});
The middleware:
- Checks if the message implements
IRequireAuthorization - Builds a
ClaimsPrincipalfrom the currentIAccessToken - Constructs appropriate authorization requirements based on the interface type
- Calls
IDispatchAuthorizationService.AuthorizeAsync() - Returns
403 Forbiddenif authorization fails
Authorization Result
Authorization results are stored in the message context and can be accessed via IMessageContextAccessor:
// Using IActionHandler with IMessageContextAccessor for context access
public class MyCommandHandler : IActionHandler<MyCommand>
{
private readonly IMessageContextAccessor _contextAccessor;
public MyCommandHandler(IMessageContextAccessor contextAccessor)
{
_contextAccessor = contextAccessor;
}
public async Task HandleAsync(MyCommand command, CancellationToken ct)
{
// Access authorization result from context (stored as extension method)
var context = _contextAccessor.MessageContext;
var authResult = context?.AuthorizationResult() as IAuthorizationResult;
// Note: Authorization middleware typically blocks unauthorized requests
// before the handler is called. This is for additional fine-grained checks.
if (authResult is not null && !authResult.IsAuthorized)
{
throw new ForbiddenException("MyCommand", "execute");
}
// Process authorized command...
}
}
Diagnosing a Denial
A 401 or 403 response body carries a generic message and never names the policy, role or permission that refused the request:
{
"title": "Authorization Failed",
"status": 403,
"detail": "You do not have permission to access this resource"
}
This matches ASP.NET Core, whose own 403 carries no reason. A legitimate caller cannot act on a policy name, and disclosing one tells an attacker enumerating your endpoints exactly which authorization vocabulary gates them.
The specific reason is written to your logs instead, at Warning, with the correlation id:
There are two denial paths and they log under different categories. Search the one your application actually uses — grepping for the wrong string finds nothing and looks like silence:
# ASP.NET Core [Authorize] path
warn: Excalibur.Dispatch.Hosting.AspNetCore.Authorization.AspNetCoreAuthorizationMiddleware
ASP.NET Core authorization denied for message type PlaceOrder (correlation 7f3a...): Policy 'AdminOnly' evaluation failed.
# A3 [RequirePermission] path
warn: Excalibur.A3.Authorization.A3AuthorizationMiddleware
A3 authorization denied (correlation 7f3a...): Condition not met: subject.Role == 'admin'
The correlation value can be empty.
MessageContext.CorrelationIdreturnsnullunless it has been set — most often from an incomingX-Correlation-IDheader, but any code that assigns it counts — or unless lazy generation is enabled, in which case one is created on first access. With neither, the line renders(correlation ). Set or propagate a correlation id if you want denials to be findable per request in a shared environment.
So when a 403 surprises you, search your logs for the correlation id rather than expecting the reason in the response body.
Attribute-Based Authorization
For simpler authorization scenarios, Excalibur supports attribute-based authorization using the [RequirePermission] attribute. This provides a cleaner syntax when you don't need complex authorization logic.
RequirePermission Attribute
Apply the attribute directly to your message class:
using Excalibur.A3.Authorization;
[RequirePermission("users.delete")]
public class DeleteUserCommand : IDispatchAction
{
public Guid UserId { get; set; }
}
Attribute Properties
| Property | Type | Description |
|---|---|---|
Permission | string | The required permission name (e.g., "users.delete") |
ResourceTypes | string[] | Optional resource types for resource-level authorization |
ResourceIdProperty | string | Property name containing the resource ID |
When | string? | Reserved for future conditional expressions (not yet implemented) |
Resource-Level Authorization
Specify resource types and the property containing the resource ID:
[RequirePermission("orders.update",
ResourceTypes = new[] { "Order" },
ResourceIdProperty = nameof(OrderId))]
public class UpdateOrderCommand : IDispatchAction
{
public Guid OrderId { get; set; }
public string Status { get; set; }
}
Resource-Level with Multiple Resource Types
Combine resource types for cross-resource permission checks:
[RequirePermission("transfers.create",
ResourceTypes = new[] { "Account" },
ResourceIdProperty = nameof(FromAccount))]
public class TransferFundsCommand : IDispatchAction
{
public decimal Amount { get; set; }
public Guid FromAccount { get; set; }
public Guid ToAccount { get; set; }
}
Multiple Permissions
Apply multiple attributes for AND logic (all permissions required):
[RequirePermission("accounts.read")]
[RequirePermission("audit.view")]
public class GetAccountAuditHistoryQuery : IDispatchAction
{
public Guid AccountId { get; set; }
}
Combining Interfaces and Attributes
When a message implements IRequireAuthorization AND has [RequirePermission], both are evaluated with AND logic:
// Both interface AND attribute requirements must pass
[RequirePermission("admin.elevated")]
public class ElevatedAdminCommand : IDispatchAction, IRequireRoleAuthorization
{
public string ActivityName => "Admin.Elevated";
public IReadOnlyCollection<string>? RequiredRoles => new[] { "Admin" };
}
Performance: AttributeAuthorizationCache
The framework uses AttributeAuthorizationCache for cached reflection lookups, avoiding repeated attribute scanning:
// Internal caching - no configuration needed
// Attributes are cached on first access per message type
// Subsequent authorizations use cached metadata
Interface vs Attribute: When to Use Each
| Approach | Use When | Benefits |
|---|---|---|
| Interfaces | Complex authorization with custom requirements | Full control, type-safe, supports computed properties |
| Attributes | Simple permission checks | Cleaner syntax, less boilerplate, declarative |
| Both | Layered security requirements | AND logic ensures both pass |
Interface Pattern (Complex Scenarios)
// Use interfaces when authorization logic is dynamic
public class TransferFundsCommand : IDispatchAction, IRequireActivityAuthorization
{
public decimal Amount { get; set; }
// Dynamic activity based on amount
public string ActivityName => Amount > 10000
? "Transfers.HighValue"
: "Transfers.Standard";
public string? ResourceId => FromAccountId.ToString();
public string[] ResourceTypes => new[] { "Account" };
}
Attribute Pattern (Simple Scenarios)
// Use attributes when authorization is static and simple
[RequirePermission("users.delete")]
public class DeleteUserCommand : IDispatchAction
{
public Guid UserId { get; set; }
}
Audit Logging
Tamper-Evident Audit Logs
The framework provides hash-chained audit logs for compliance:
// Default in-memory audit store (for development/testing)
builder.Services.AddAuditLogging();
// Or use SQL Server for production (includes options configuration)
builder.Services.AddSqlServerAuditStore(options =>
{
options.ConnectionString = builder.Configuration.GetConnectionString("Compliance");
options.SchemaName = "audit";
options.EnableHashChain = true;
});
Hash chaining is automatically enabled via AuditHasher. For SOC 2 compliance, use a persistent audit store with appropriate retention policies at the database level.
Audit Event Recording
public class OrderService
{
private readonly IAuditLogger _auditLogger;
public async Task PlaceOrderAsync(Order order, CancellationToken ct)
{
// Business logic...
await _auditLogger.LogAsync(new AuditEvent
{
EventId = Guid.NewGuid().ToString(),
EventType = AuditEventType.DataModification,
Action = "OrderPlaced",
Outcome = AuditOutcome.Success,
Timestamp = DateTimeOffset.UtcNow,
ActorId = order.CustomerId.ToString(),
ResourceType = "Order",
ResourceId = order.Id.ToString(),
Metadata = new Dictionary<string, string>
{
["total"] = order.Total.ToString(),
["items"] = order.Items.Count.ToString()
}
}, ct);
}
}
Audit Middleware
builder.Services.AddDispatch(options =>
{
options.AddMiddleware<AuditLoggingMiddleware>();
});
public class AuditLoggingMiddleware : IDispatchMiddleware
{
private readonly IAuditLogger _auditLogger;
public AuditLoggingMiddleware(IAuditLogger auditLogger)
{
_auditLogger = auditLogger;
}
public DispatchMiddlewareStage? Stage => DispatchMiddlewareStage.PostProcessing;
public async ValueTask<IMessageResult> InvokeAsync(
IDispatchMessage message,
IMessageContext context,
DispatchRequestDelegate nextDelegate,
CancellationToken cancellationToken)
{
var startTime = DateTimeOffset.UtcNow;
var outcome = AuditOutcome.Success;
try
{
return await nextDelegate(message, context, cancellationToken);
}
catch (Exception)
{
outcome = AuditOutcome.Failure;
throw;
}
finally
{
await _auditLogger.LogAsync(new AuditEvent
{
EventId = Guid.NewGuid().ToString(),
EventType = AuditEventType.DataAccess,
Action = message.GetType().Name,
Outcome = outcome,
Timestamp = startTime,
ActorId = context.CorrelationId ?? "system",
CorrelationId = context.CorrelationId,
}, CancellationToken.None).ConfigureAwait(false);
}
}
}
Key Management
Key Rotation
Configure automatic key rotation for compliance:
builder.Services.AddKeyRotation(options =>
{
options.Policy = KeyRotationPolicy.Default; // 90-day rotation
options.AutoRotateEnabled = true;
options.WarningDaysBeforeRotation = 14;
});
// KeyRotationService is a BackgroundService registered via AddKeyRotation
Key Rotation Policies
| Policy | Max Age | Use Case |
|---|---|---|
Default | 90 days | SOC 2 / PCI DSS compliance |
HighSecurity | 30 days | Financial data, FIPS required |
Archival | 365 days | Long-term storage |
Multi-Region Keys
For disaster recovery and data sovereignty:
builder.Services.AddMultiRegionKeyManagement(options =>
{
options.PrimaryRegion = "us-east-1";
options.ReplicaRegions = new[] { "eu-west-1", "ap-south-1" };
options.SyncStrategy = KeySyncStrategy.CloudNative;
});
Secret Management
Azure Key Vault
builder.Configuration.AddAzureKeyVault(
new Uri(configuration["KeyVault:Uri"]),
new DefaultAzureCredential());
builder.Services.AddAzureKeyVaultKeyManagement(options =>
{
options.VaultUri = new Uri(configuration["KeyVault:Uri"]);
options.RequirePremiumTier = true; // HSM-backed keys
});
AWS Secrets Manager
builder.Configuration.AddSecretsManager(options =>
{
options.SecretFilter = entry => entry.Name.StartsWith("dispatch/");
});
builder.Services.AddAwsKmsKeyManagement(options =>
{
options.KeyId = configuration["AWS:KmsKeyId"];
options.UseFipsEndpoint = true;
});
HashiCorp Vault
builder.Services.AddVaultKeyManagement(vault =>
vault.VaultUri(new Uri(configuration["Vault:Address"]!))
.TransitMountPath("transit"));
builder.Services.Configure<VaultOptions>(options =>
{
options.Auth.AuthMethod = VaultAuthMethod.AppRole;
});
Password Hashing
Excalibur.Security ships an Argon2id password hasher behind IPasswordHasher. Argon2id is the
OWASP-recommended choice for credential storage: it resists both the side-channel attacks Argon2d is
exposed to and the GPU attacks Argon2i is exposed to.
Registration
using Microsoft.Extensions.DependencyInjection;
builder.Services.AddPasswordHasher();
// or configure the work factors explicitly
builder.Services.AddPasswordHasher(options =>
{
options.MemorySize = 65536; // KiB
options.Iterations = 4;
options.Parallelism = 4;
});
AddPasswordHasher registers Argon2idPasswordHasher as a singleton IPasswordHasher via
TryAddSingleton, so your own implementation registered first is kept.
Hashing and verifying
public sealed class CredentialService(IPasswordHasher hasher)
{
public async Task<PasswordHashResult> RegisterAsync(string password, CancellationToken ct)
=> await hasher.HashPasswordAsync(password, ct);
public async Task<bool> SignInAsync(
string password,
PasswordHashResult stored,
CancellationToken ct)
{
var result = await hasher.VerifyPasswordAsync(password, stored, ct);
return result switch
{
PasswordVerificationResult.Success => true,
// Correct password, but the stored hash predates your current work factors.
// Re-hash and persist before returning.
PasswordVerificationResult.SuccessRehashNeeded => true,
_ => false,
};
}
}
PasswordHashResult carries Hash, Salt, Algorithm, Version, and the Parameters the hash was
produced with — persist the whole record, not just the hash, so a later parameter change is detectable.
SuccessRehashNeeded is what makes that migration gradual: raise the work factors, and each user's hash
is upgraded on their next successful sign-in without a password reset.
Options
| Option | Default | Meaning |
|---|---|---|
MemorySize | 65536 | Memory cost in KiB |
Iterations | 4 | Time cost (passes) |
Parallelism | 4 | Degree of parallelism |
HashLength | 32 | Output length in bytes |
SaltLength | 16 | Salt length in bytes |
Version | 1 | Your parameter-set version, used to detect rehash-needed |
Bind them from configuration under the Argon2 section, or pass an Action<Argon2Options>. The
configuration-binding overload uses reflection; on Native AOT use the Action<Argon2Options> overload.
Input Validation
FluentValidation Integration
builder.Services.AddDispatch(options =>
{
options.AddMiddleware<ValidationMiddleware>();
});
builder.Services.AddValidatorsFromAssemblyContaining<CreateOrderCommandValidator>();
public class CreateOrderCommandValidator : AbstractValidator<CreateOrderCommand>
{
public CreateOrderCommandValidator()
{
RuleFor(x => x.CustomerId)
.NotEmpty()
.WithMessage("CustomerId is required");
RuleFor(x => x.Total)
.GreaterThan(0)
.WithMessage("Total must be positive");
RuleFor(x => x.Items)
.NotEmpty()
.WithMessage("Order must have at least one item");
}
}
SQL Injection Prevention
Always use parameterized queries with Dapper:
// CORRECT - Parameterized query
var order = await connection.QuerySingleOrDefaultAsync<Order>(
"SELECT * FROM Orders WHERE Id = @Id",
new { Id = orderId });
// INCORRECT - SQL injection vulnerability
var order = await connection.QuerySingleOrDefaultAsync<Order>(
$"SELECT * FROM Orders WHERE Id = '{orderId}'"); // NEVER do this!
CORS and CSP
CORS Configuration
builder.Services.AddCors(options =>
{
options.AddPolicy("Production", policy =>
{
policy.WithOrigins("https://app.example.com")
.AllowCredentials()
.WithMethods("GET", "POST", "PUT", "DELETE")
.WithHeaders("Authorization", "Content-Type");
});
});
Content Security Policy
app.Use(async (context, next) =>
{
context.Response.Headers.Add(
"Content-Security-Policy",
"default-src 'self'; script-src 'self'; style-src 'self'");
await next();
});
Security Headers
app.Use(async (context, next) =>
{
var headers = context.Response.Headers;
headers.Add("X-Content-Type-Options", "nosniff");
headers.Add("X-Frame-Options", "DENY");
headers.Add("X-XSS-Protection", "1; mode=block");
headers.Add("Referrer-Policy", "strict-origin-when-cross-origin");
headers.Add("Permissions-Policy", "geolocation=(), microphone=()");
await next();
});
Security Checklist
Development
- Use parameterized queries (Dapper)
- Validate all user input
- Never log sensitive data
- Use secrets manager for credentials
Production
- Enable TLS 1.2+ only
- Configure CORS appropriately
- Enable security headers
- Configure key rotation
- Enable audit logging
- Set up alerting for security events
Compliance
- Review SOC 2 Checklist
- Review GDPR Checklist
- Document security controls
- Schedule penetration testing
Related Documentation
- Encryption Architecture - Data protection and key management
- Audit Logging Guide - Audit implementation
- Compliance Checklists - Regulatory guidance
- Deployment Guide - Secure deployment patterns
See Also
- Encryption Providers — Detailed guide to AES-256-GCM, Azure Key Vault, AWS KMS, and HashiCorp Vault providers
- Authorization — In-depth authorization patterns and policy configuration
- Audit Logging — Tamper-evident hash chain implementation and compliance logging
- Encryption Architecture — Data protection design and key management internals