Skip to content
Kumar Chandrachooda
Microservices

Two Push Channels, Two Security Models

Pacco's Operations service narrates the estate twice - once over SignalR with a JWT handshake and per-user groups, once over a gRPC stream with no auth, no filtering, a per-call event-handler leak and a blocking Take. Same data, same service, two security models.

By Kumar Chandrachooda 28 Dec 2025 7 min read
One tracker, two mouths - one checks who is listening, one does not

The 202 loop ended on a cliff-hanger: Pacco.Services.Operations — the service that watches every message in the estate and tells your browser what became of your request — has a second mouth. The first is a SignalR hub with a deliberate authentication handshake and per-user groups. The second is a gRPC stream that will narrate every user's operations to any caller who connects, leaking an event handler per subscriber while it does. The same OperationDto, produced by the same Redis state machine, leaves the same process through two channels with two entirely different security models.

This part reads both channels in the source, because the contrast is the lesson. Push is an API surface, and every transport you bolt onto a service needs the same answers — who are you, what may you see — not just the transport the demo happens to use.

The handshake channel

Hubs/PaccoHub.cs is the whole SignalR surface, and its authentication is worth quoting because it is not the [Authorize] attribute you would expect:

public async Task InitializeAsync(string token)
{
    if (string.IsNullOrWhiteSpace(token))
    {
        await DisconnectAsync();
    }
    try
    {
        var payload = _jwtHandler.GetTokenPayload(token);
        if (payload is null)
        {
            await DisconnectAsync();
            return;
        }

        var group = Guid.Parse(payload.Subject).ToUserGroup();
        await Groups.AddToGroupAsync(Context.ConnectionId, group);
        await ConnectAsync();
    }
    catch
    {
        await DisconnectAsync();
    }
}

Clients connect anonymously, then introduce themselves by invoking InitializeAsync with a JWT. The hub validates it with Convey's IJwtHandler, parses the subject claim, and joins the connection to the group users:{id}ToUserGroup in Infrastructure/Extensions.cs formats the GUID with ToString("N"), matching the dash-less form Identity stamps into its tokens. From then on, every publish in Services/HubService.cs is group-targeted: operation_pending, operation_completed and operation_rejected all go through PublishToUserAsync(operation.UserId, ...), where the user id was captured by the gateway into the correlation context at the moment of the original request. A connection that never proves who it is receives nothing, because nothing is ever published to connections — only to groups.

That is a sound design with two soft findings sitting inside it. First, a failed handshake replies "disconnected" but never calls Context.Abort() — the rejected connection stays open, deaf but connected, until the client goes away on its own. Second, look again at the whitespace guard at the top: it calls DisconnectAsync() and then falls through into the try block, because there is no return. A client that invokes InitializeAsync("") receives "disconnected" twice — once from the guard, once from the catch after GetTokenPayload chokes on the empty string. Both are harmless in effect and instructive in kind: guard clauses that do not return are one of those bugs that survive precisely because their symptom is invisible.

The scale story is also configured rather than coded. appsettings.json carries signalR.backplane: "redis", and the private AddSignalR extension chains .AddRedis(redisOptions.ConnectionString) onto the hub builder when it sees that value — group membership and fan-out ride a Redis backplane, so two Operations instances would still deliver to a user connected to either. The ambition is right; the packages betray the era. The csproj pins Microsoft.AspNetCore.SignalR 1.1.0 and Microsoft.AspNetCore.SignalR.Redis 1.1.5 — the ASP.NET Core 2.x-era NuGets, on a netcoreapp3.1 app where SignalR had already moved into the shared framework and the Redis backplane had been superseded by Microsoft.AspNetCore.SignalR.StackExchangeRedis. It works, because the old packages still resolve; it is also the estate's clearest specimen of a dependency that outlived its own package id. (And two lines above the SignalR wiring, the same builder chain calls Convey's .AddRedis() twice — harmless, and a fair measure of how often this file was re-read.)

The channel with no questions

Now the second mouth. Operations.proto declares two RPCs — a unary GetOperation and a server-streaming SubscribeOperations — and Infrastructure/GrpcServiceHost.cs implements them in fifty lines:

public GrpcServiceHost(IOperationsService operationsService, ILogger<GrpcServiceHost> logger)
{
    _operationsService = operationsService;
    _logger = logger;
    _operationsService.OperationUpdated += (s, e) => _operations.TryAdd(e.Operation);
}

public override async Task SubscribeOperations(Empty request,
    IServerStreamWriter<GetOperationResponse> responseStream, ServerCallContext context)
{
    _logger.LogInformation($"Received 'Subscribe operations' request from: {context.Peer}");
    while (true)
    {
        var operation = _operations.Take();
        await responseStream.WriteAsync(Map(operation));
    }
}

Read it slowly, because every line teaches something — mostly by contrast with the hub.

  • No authentication, no filtering. There is no token handshake, no metadata check, no per-user anything. OperationsService is registered as a singleton; its OperationUpdated event fires for every state change in the estate; the stream forwards each one — id, user id, operation name, failure reason — to whoever connected. The SignalR channel scopes the exact same DTO to users:{id}; the gRPC channel broadcasts it to context.Peer, unasked.
  • A handler leak per call. gRPC service classes are constructed per call, but the constructor subscribes a lambda to an event on the singleton service and nothing ever unsubscribes it. Every subscriber that connects — and every one that disconnects — leaves a live delegate behind, each feeding operations into its own BlockingCollection<OperationDto> that no consumer will ever drain again. Dead hosts accumulate on the event for the life of the process; memory grows with churn, not with load.
  • A blocked thread per subscriber. _operations.Take() is a blocking call sitting inside an async method — each streaming subscriber parks a thread-pool thread between operations. while (true) also ignores context.CancellationToken, so a departed client's loop only learns the news when the next WriteAsync throws.

And then the finding that reframes all the others: in Docker, this channel does not exist. gRPC needs HTTP/2, and the only HTTP/2 endpoint is in Properties/launchSettings.jsonhttps://localhost:50050, the local launch profile. The Dockerfile sets ASPNETCORE_URLS http://*:80 and no Kestrel endpoint configuration anywhere adds an HTTP/2 listener, so the container serves HTTP/1.1 only and MapGrpcService<GrpcServiceHost>() maps a service nothing can reach. The companion console app in Pacco.Services.Operations.GrpcClient confirms the intended audience: it defaults to https://localhost:50050 and switches off certificate validation with DangerousAcceptAnyServerCertificateValidator, above the authors' own comment — “Only for the local development purposes.”

To be fair to the estate: that comment is the honest frame. The gRPC surface is a teaching artefact — a second transport bolted onto the same tracker so a course can show streaming RPC next to SignalR, run from a laptop against a local profile. The leak, the blocking loop and the missing auth are all defensible as demo economics. What makes them worth an article is how quietly they would survive promotion: the day someone adds http2 to the Kestrel config because a colleague wants the stream in staging, every one of these properties ships with it, and the carefully scoped SignalR channel now has an unscoped twin on the same data.

The same DTO, three postures

Counting the polling endpoint from part 5, Operations exposes one DTO through three transports, each with its own idea of who may read it:

Transport Surface Who can read it
SignalR /pacco hub, group users:{id} The initiating user, after a JWT handshake
HTTP GET /operations/{operationId} Anyone holding the GUID (auth: false at the gateway)
gRPC SubscribeOperations stream Anyone who can reach the port — all users' operations

That table is the article. Nobody decided that operation data should be user-scoped on one channel, bearer-by-GUID on another and public on the third; each transport simply got the security model that was idiomatic to write on the day it was added. The hub got a handshake because SignalR makes groups easy. The REST route got auth: false because polling before sign-in is convenient in a demo. The gRPC stream got nothing because interceptors are the part of gRPC that samples skip. Security posture in this estate is a property of the transport, not of the data — and data does not care which pipe it leaves through.

My rule of thumb from this pair of files: when a service grows a second push channel, port the first channel's questions before you port the data — who are you, what subset is yours, and what happens to your resources when you leave. The SignalR hub answers all three; the gRPC stream answers none; both compile, both demo well, and only one of them is an API you could ship.

There is one thread left hanging. The hub trusts operation.UserId — but that value was not minted by Operations. It was stamped at the gateway, serialised into a JSON blob, carried across HTTP as one header and across RabbitMQ as another, and forwarded by a copy-pasted broker class in every service it passed through. That blob is the estate's real nervous system, and it deserves its own anatomy: the correlation spine.