Skip to main content

Security Guide

This guide covers security hardening for Excalibur applications, including encryption, authentication, authorization, and audit logging.

Before You Start​

Overview​

Excalibur provides multiple security capabilities for building compliant, secure applications:

CapabilityDescriptionStandards
EncryptionField-level AES-256-GCM encryptionSOC 2 CC6, GDPR Art. 32
AuthorizationRole-based access controlSOC 2 CC5
Audit LoggingTamper-evident hash chainsSOC 2 CC4
Key ManagementCloud KMS integrationSOC 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 hold

In 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​

ProviderUse CaseConfiguration
Azure Key VaultProduction, AzureAddAzureKeyVaultKeyManagement()
AWS KMSProduction, AWSAddAwsKmsKeyManagement()
HashiCorp VaultMulti-cloudAddVaultKeyManagement()

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​

InterfacePurposeKey Properties
IRequireAuthorizationBase interface for all authorized messagesActivityName
IRequireRoleAuthorizationRole-based authorizationRequiredRoles
IRequireActivityAuthorizationActivity/resource-based authorizationResourceId, ResourceTypes
IRequireCustomAuthorizationCustom authorization requirementsAuthorizationRequirements

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:

  1. Checks if the message implements IRequireAuthorization
  2. Builds a ClaimsPrincipal from the current IAccessToken
  3. Constructs appropriate authorization requirements based on the interface type
  4. Calls IDispatchAuthorizationService.AuthorizeAsync()
  5. Returns 403 Forbidden if 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.CorrelationId returns null unless it has been set — most often from an incoming X-Correlation-ID header, 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​

PropertyTypeDescription
PermissionstringThe required permission name (e.g., "users.delete")
ResourceTypesstring[]Optional resource types for resource-level authorization
ResourceIdPropertystringProperty name containing the resource ID
Whenstring?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​

ApproachUse WhenBenefits
InterfacesComplex authorization with custom requirementsFull control, type-safe, supports computed properties
AttributesSimple permission checksCleaner syntax, less boilerplate, declarative
BothLayered security requirementsAND 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​

PolicyMax AgeUse Case
Default90 daysSOC 2 / PCI DSS compliance
HighSecurity30 daysFinancial data, FIPS required
Archival365 daysLong-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​

OptionDefaultMeaning
MemorySize65536Memory cost in KiB
Iterations4Time cost (passes)
Parallelism4Degree of parallelism
HashLength32Output length in bytes
SaltLength16Salt length in bytes
Version1Your 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​


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