Skip to content

Security Context Propagation

Verified by tests

MessageHopSecurityExtractorTests, MessageSecurityContextProviderTests, MessageSecurityIntegrationTests, MessageSecurityOptionsTests, MessageSecurityServiceCollectionExtensionsTests, ImmutableScopeContextTests, ScopeContextAccessorTests — library CI run #31657041675 (2026-08-13)

Security context propagation ensures that security identity (TenantId, UserId, roles, permissions) flows seamlessly across service boundaries in distributed systems. Whizbang provides automatic propagation from HTTP requests through message processing without manual intervention.

Overview

In distributed systems, security context must flow across multiple hops:

  1. HTTP Request → API receives authenticated user request
  2. Message Dispatch → API sends command/event to message bus
  3. Message Transport → Azure Service Bus, RabbitMQ, etc.
  4. Message Receipt → Consumer service receives message
  5. Business Logic → Handler executes with original security context

Without propagation, each service must manually extract and forward security information. With Whizbang, security context flows automatically.

Architecture

flowchart TD
    Request["HTTP Request<br/>(Bearer Token / Cookie)"]
    Middleware["WhizbangScopeMiddleware (HTTP)<br/>• Extracts JWT claims<br/>• Populates IScopeContextAccessor"]
    Logic["Business Logic / Controller<br/>• Calls dispatcher.SendAsync()"]
    Dispatcher["Dispatcher (Outgoing Messages)<br/>• Reads IScopeContextAccessor<br/>• Attaches to MessageHop"]
    Transport["Transport (Azure Service Bus, etc.)<br/>• Carries MessageHop.SecurityContext"]
    Consumer["ServiceBusConsumerWorker (Incoming)<br/>• Creates DI scope<br/>• Calls provider.EstablishContextAsync()"]
    Extractor["MessageHopSecurityExtractor<br/>• Reads MessageHop.SecurityContext<br/>• Populates IScopeContextAccessor"]
    Callbacks["ISecurityContextCallback[]<br/>• Initialize custom services<br/>• Populate UserContextManager"]
    Receptor["Receptor / Business Logic<br/>• Executes with full security ctx"]

    Request --> Middleware
    Middleware --> Logic
    Logic --> Dispatcher
    Dispatcher --> Transport
    Transport --> Consumer
    Consumer --> Extractor
    Extractor --> Callbacks
    Callbacks --> Receptor

HTTP to Message Propagation

Step 1: HTTP Request Establishes Context

The WhizbangScopeMiddleware (shipped in Whizbang.Transports.HotChocolate) extracts security context from HTTP headers and JWT claims. Register with AddWhizbangScope() / UseWhizbangScope() and configure via WhizbangScopeOptions:

Step 1: HTTP Request Establishes Context

// Program.cs
builder.Services.AddWhizbangScope(options => {
  options.TenantIdClaimTypes = ["tenant_id"];        // JWT claim(s) for tenant
  options.UserIdClaimTypes = ["sub", "oid"];         // JWT claim(s) for user
  options.TenantIdHeaderName = "X-Tenant-Id";        // header fallback
});

app.UseWhizbangScope();

This middleware: - Extracts claims from JWT bearer tokens (and header fallbacks) - Maps claims to IScopeContext properties - Populates IScopeContextAccessor.Current - Makes context available to downstream code

Step 2: Dispatcher Reads Ambient Context

When business logic dispatches a message, the dispatcher automatically reads the ambient security context:

Step 2: Dispatcher Reads Ambient Context

// In your controller or service
public class OrderController : ControllerBase {
  private readonly IDispatcher _dispatcher;

  [HttpPost]
  public async Task<IActionResult> CreateOrder(CreateOrderRequest request) {
    // Dispatcher reads IScopeContextAccessor.Current automatically
    await _dispatcher.SendAsync(new CreateOrder {
      CustomerId = request.CustomerId,
      Items = request.Items
    });

    return Ok();
  }
}

No manual context passing required - the dispatcher finds it via IScopeContextAccessor.

Step 3: Security Context Attached to MessageHop

The dispatcher attaches the scope to the message's hop as a ScopeDelta (MessageHop.Scope) — only the changes from the previous hop are serialized (see Scope Propagation):

Step 3: Scope Attached to MessageHop

// Inside the dispatcher (conceptual)
var scopeContext = _scopeContextAccessor.Current;

if (scopeContext is ImmutableScopeContext immutable && immutable.ShouldPropagate) {
  var hop = new MessageHop {
    Type = HopType.Current,
    ServiceInstance = _serviceInstance,
    // Full scope on the first hop; only the delta from the previous hop afterwards
    Scope = ScopeDelta.CreateDelta(previousScope, scopeContext)
  };

  envelope.Hops.Add(hop);
}

Key Point: ImmutableScopeContext.ShouldPropagate controls whether security flows to downstream services. MessageHop has no SecurityContext property — the scope travels as ScopeDelta on MessageHop.Scope (wire name "sc").

Step 4: Message Serialized with SecurityContext

The message envelope, including the hop chain with its scope delta, is serialized and sent to the transport. Hop properties use abbreviated wire names (ty = Type, si = ServiceInstance, ts = Timestamp, sc = Scope delta):

Step 4: Message Serialized with Scope Delta

{
  "messageId": "123e4567-e89b-12d3-a456-426614174000",
  "messageType": "MyApp.Orders.CreateOrder",
  "payload": { "customerId": "cust-456", "items": [] },
  "hops": [
    {
      "ty": 0,
      "si": { "sn": "OrderApi", "ii": "3f6b…", "hn": "orderapi-prod-1", "pi": 1234 },
      "ts": "2026-03-03T10:00:00Z",
      "sc": {
        "v": { "Sc": { "t": "tenant-123", "u": "user-789", "o": "org-456" } }
      }
    }
  ]
}

Message to Handler Propagation

Step 5: Consumer Receives Message

The ServiceBusConsumerWorker receives the message from the transport and deserializes the envelope:

Step 5: Consumer Receives Message

// Inside ServiceBusConsumerWorker (conceptual)
var envelope = await DeserializeEnvelopeAsync(serviceBusMessage);

// Create DI scope for this message
await using var scope = _serviceProvider.CreateAsyncScope();

// Establish security context BEFORE executing handlers
// (IMessageSecurityContextProvider.EstablishContextAsync returns the IScopeContext)
await _securityContextProvider.EstablishContextAsync(
  envelope,
  scope.ServiceProvider,
  cancellationToken);

// Now dispatch the message to its receptors (internal consumer path)
await DispatchToReceptorsAsync(envelope, scope.ServiceProvider, cancellationToken);

Step 6: Security Context Extracted from Hops

The MessageHopSecurityExtractor merges the ScopeDelta from every Current hop (via ScopeDelta.ApplyTo) to rebuild the full scope — roles, permissions, principals, claims, and impersonation info included:

Step 6: Security Context Extracted from Hops

public sealed partial class MessageHopSecurityExtractor : ISecurityContextExtractor {
  // Extractors run in ascending Priority order (lower runs first).
  public int Priority => 100;

  public ValueTask<SecurityExtraction?> ExtractAsync(
    IMessageEnvelope envelope,
    MessageSecurityOptions options,
    CancellationToken cancellationToken = default) {

    // Merge ScopeDelta from all Current hops to produce the full ScopeContext
    var scopeContext = _mergeScopeDeltas(envelope.Hops, _logger, envelope.MessageId);

    // No scope in the hop chain, or empty scope (no TenantId/UserId) → nothing to extract
    if (scopeContext is null ||
        (string.IsNullOrEmpty(scopeContext.Scope.TenantId) &&
         string.IsNullOrEmpty(scopeContext.Scope.UserId))) {
      return ValueTask.FromResult<SecurityExtraction?>(null);
    }

    // Map to SecurityExtraction with FULL context from the merged deltas
    return ValueTask.FromResult<SecurityExtraction?>(new SecurityExtraction {
      Scope = scopeContext.Scope,
      Roles = scopeContext.Roles,
      Permissions = scopeContext.Permissions,
      SecurityPrincipals = scopeContext.SecurityPrincipals,
      Claims = scopeContext.Claims,
      ActualPrincipal = scopeContext.ActualPrincipal,
      EffectivePrincipal = scopeContext.EffectivePrincipal,
      ContextType = scopeContext.ContextType,
      Source = "MessageHop"
    });
  }
}

Step 7: Context Populated and Callbacks Invoked

The DefaultMessageSecurityContextProvider establishes the context:

Step 7: Context Populated and Callbacks Invoked

// 1. Extract security (via MessageHopSecurityExtractor)
var extraction = await extractor.ExtractAsync(envelope, options, ct);

// 2. Wrap in ImmutableScopeContext
var context = new ImmutableScopeContext(extraction, shouldPropagate: true);

// 3. Invoke the audit callback (if EnableAuditLogging and a callback is wired)
if (options.EnableAuditLogging) {
  onAuditEvent?.Invoke(new ScopeContextEstablished {
    Scope = context.Scope,
    Roles = context.Roles,
    Permissions = context.Permissions,
    Source = "MessageHop",
    Timestamp = DateTimeOffset.UtcNow
  });
}

// 4. Invoke all callbacks
foreach (var callback in callbacks) {
  await callback.OnContextEstablishedAsync(context, envelope, scopedProvider, ct);
}

// 5. Return the context — the caller (SecurityContextHelper) sets
//    IScopeContextAccessor.Current with it for the message's DI scope
return context;

Step 8: Handler Executes with Context

The receptor now has full access to the original security context:

Step 8: Handler Executes with Context

public class CreateOrderReceptor : IReceptor<CreateOrder> {
  private readonly IScopeContextAccessor _scopeAccessor;
  private readonly IScopedLensFactory _lensFactory;

  public async ValueTask HandleAsync(CreateOrder message, CancellationToken ct) {
    var context = _scopeAccessor.Current!;

    // Same TenantId and UserId from original HTTP request
    Console.WriteLine($"Tenant: {context.Scope.TenantId}");
    Console.WriteLine($"User: {context.Scope.UserId}");

    // Scoped READS use the propagated context (lenses are read models —
    // writes happen through events applied by perspectives)
    var orderLens = _lensFactory.GetUserLens<IOrderLens>();
    var myOrders = await orderLens.Query.ToListAsync(ct);
  }
}

Controlling Propagation

Enable/Disable Globally

Enable/Disable Globally

services.AddWhizbangMessageSecurity(options => {
  // Enable/disable propagation globally
  options.PropagateToOutgoingMessages = true; // default
});

Per-Context Control

Per-Context Control

// Create context with propagation enabled
var extraction = new SecurityExtraction { /* ... */ };
var propagate = new ImmutableScopeContext(extraction, shouldPropagate: true);

// Create context that stays local (no propagation)
var local = new ImmutableScopeContext(extraction, shouldPropagate: false);

Explicit Context Override

For system operations or impersonation, use explicit context:

Explicit Context Override

// System context (no user) — a tenant strategy is REQUIRED before SendAsync
await dispatcher.AsSystem().KeepTenant().SendAsync(new MaintenanceCommand());
// Scope delta on hop: { ContextType = System, EffectivePrincipal = "SYSTEM" }

// Impersonation context — same tenant-strategy requirement
await dispatcher.RunAs("target-user@example.com").ForTenant("user-tenant").SendAsync(command);
// Scope delta on hop: { ContextType = Impersonated, ActualPrincipal = "admin@...", EffectivePrincipal = "target-user@..." }

Multi-Hop Propagation

Security context flows across multiple service hops:

HTTP → Service A → Service B → Service C

User makes request
    ↓ (JWT)
Service A (API)
    ↓ MessageHop.Scope (ScopeDelta) = { TenantId, UserId }
Service B (Worker)
    ↓ hop without "sc" → scope inherited unchanged
Service C (Processor)
    ↓ All services see same TenantId, UserId

Each service adds a new hop to the chain. A hop without an "sc" (ScopeDelta) property inherits the scope unchanged from the previous hop — nothing is re-serialized when nothing changed:

Multi-Hop Propagation

{
  "hops": [
    {
      "ty": 0,
      "si": { "sn": "ServiceA" },
      "sc": { "v": { "Sc": { "t": "t1", "u": "u1" } } }
    },
    {
      "ty": 0,
      "si": { "sn": "ServiceB" }
    }
  ]
}

(ty: 0 = HopType.Current; ty: 1 = HopType.Causation, a hop carried forward from the parent message for distributed tracing.)

Audit Trail

Every security context establishment is audited (when EnableAuditLogging = true):

Audit Trail

public sealed record ScopeContextEstablished : ISystemEvent {
  public Guid Id { get; init; } = TrackedGuid.NewMedo();
  public required PerspectiveScope Scope { get; init; }
  public required IReadOnlySet<string> Roles { get; init; }
  public required IReadOnlySet<Permission> Permissions { get; init; }
  public required string Source { get; init; }  // "MessageHop", "JwtPayload", etc.
  public required DateTimeOffset Timestamp { get; init; }
}

This enables: - Security audits: Who accessed what, when - Compliance: GDPR, HIPAA, SOC 2 audit trails - Debugging: Trace security context flow across services - Monitoring: Detect unauthorized access attempts

Security Considerations

1. Trust Boundaries

Problem: Services within the trust boundary should accept the scope carried on MessageHop.Scope from other services, but messages from external sources should not.

Solution: Use different extractors for internal vs external messages:

Trust Boundaries

// Internal service-to-service: Trust MessageHop (built-in, Priority 100)
services.AddSecurityExtractor<MessageHopSecurityExtractor>();

// External API: your own extractor that validates a JWT in the payload.
// Extractors run in ascending Priority order — Priority 50 runs BEFORE 100.
services.AddSecurityExtractor<JwtPayloadExtractor>(); // custom ISecurityContextExtractor, Priority 50

2. Token Expiration

Problem: Long-running message processing may outlive the original JWT token.

Solution: Extract security at message ingress, not at processing time. The scope on MessageHop.Scope is a snapshot, not a live token.

3. Privilege Escalation

Problem: Malicious service could forge the MessageHop.Scope delta to impersonate users.

Solution: - Use message signing/encryption for cross-service communication - Validate message signatures before trusting security context - Use AsSystem() or RunAs() with explicit audit trails for elevated operations

4. Cross-Tenant Isolation

Problem: Bug in one service could leak data across tenants.

Solution: - Always use IScopedLensFactory for queries (automatic tenant filtering) - Enable audit logging to detect cross-tenant access attempts - Use database-level row-level security (RLS) as defense-in-depth

Integration with UserContextManager

For legacy systems with existing UserContextManager, use a callback to bridge:

Integration with UserContextManager

public class UserContextManagerCallback : ISecurityContextCallback {
  private readonly UserContextManager _userContextManager;

  public UserContextManagerCallback(UserContextManager userContextManager) {
    _userContextManager = userContextManager;
  }

  public ValueTask OnContextEstablishedAsync(
    IScopeContext context,
    IMessageEnvelope envelope,
    IServiceProvider scopedProvider,
    CancellationToken cancellationToken = default) {

    // Populate UserContextManager from Whizbang security context
    if (context?.Scope != null) {
      _userContextManager.SetFromScopeContext(
        tenantId: context.Scope.TenantId,
        userId: context.Scope.UserId
      );
    }

    return ValueTask.CompletedTask;
  }
}

// Register callback
services.AddSecurityContextCallback<UserContextManagerCallback>();

This ensures UserContextManager is populated before any receptor code runs.

Best Practices

DO

  • Enable audit logging for compliance and debugging
  • Use IScopedLensFactory for all queries to ensure tenant isolation
  • Trust MessageHop security context within your service boundary
  • Use callbacks to initialize custom services with security context
  • Test cross-service flows to verify security propagation

DON'T

  • Don't bypass scoped lenses with raw SQL or global queries
  • Don't trust security context from external/untrusted sources without validation
  • Don't cache security context across requests (it's request-scoped)
  • Don't disable propagation unless you have a strong reason
  • Don't forget to test security isolation in multi-tenant scenarios

Version 1.0.0 - Foundation Release