Skip to main content
Dejan Demonjić brand logoDejan Demonjić
Decision log
Work
About
Contact

Decision log / Decision record

SignalR between a cloud and its sites

A hub that pushes changes from a cloud to on-premises sites is a security boundary and a lifecycle problem: who may connect, what each connection hears, how long an admission holds, and how a client comes back after the server closes it. The design note: a service-client policy, a group per tenant, a fifteen-second sweep with the same check requests get, and a client that starts itself again because automatic reconnect does not follow a server-side Abort.

Filed
2026-10-16
Topic
Shipping products
Reading
8 min
Tags
#signalr#aspnetcore#multi-tenancy#realtime#security

Part 2 of 2 Realtime transports

  1. Server-Sent Events in ASP.NET Core 10, and when to pick them over SignalR
  2. SignalR between a cloud and its sites
Previous Server-Sent Events in ASP.NET Core 10, and when to pick them over SignalR

The claim

A SignalR hub that pushes changes from a cloud to the sites that run on customer premises is two things at once: a security boundary and a lifecycle problem. Four questions decide its design. Who may connect. What each connection hears. How long an admission holds once the connection is open. And how the client comes back after the server closes it, because the framework's automatic reconnect does not follow a server-side Abort. Here is how Hexavera answers each, and what in the framework made the answers look the way they do.

Context

A site runtime polls the cloud for changes every sixty seconds while it has no hub connection, and every five minutes, as a backstop, while it has one. The hub is an accelerator: the cloud tells the site which entity changed, and the site syncs that entity at once, so the site learns of a change as it happens rather than at the next poll. If the hub is down, nothing is lost, only slower.

The first version of the hub asked for a signed-in caller and nothing more, and sent every change to every connection. An authorization review named the consequence in one sentence: any user of any tenant could connect and watch which entities every other tenant changed, and when. The policy and the tenant group shipped on 2026-10-02; the fifteen-second sweep and the client's loop followed on 2026-10-11 and 2026-10-12. The design as it stands is below.

Who may connect, and what they hear

Only a site connects to this hub, and a site is a service client. The hub carries an authorization policy that requires a service-client token holding the sync scope; a person's token never passes it, however many roles it carries. On connect, the hub reads the tenant from the token's claim, refuses a token that names none, and joins the connection to a group named after that tenant.

[Authorize(Policy = SiteSyncPolicy)]
public sealed class SiteSyncHub(OpenConnections connections) : Hub
{
    public static string GroupOf(Guid tenantId) => "tenant:" + tenantId.ToString("N");

    public override async Task OnConnectedAsync()
    {
        if (TenantOf(Context.User) is not { } tenantId)
        {
            Context.Abort();                                   // a token with no tenant hears nothing
            return;
        }

        await Groups.AddToGroupAsync(Context.ConnectionId, GroupOf(tenantId), Context.ConnectionAborted);
        connections.Opened(Context);                           // judged again every fifteen seconds
        await base.OnConnectedAsync();
    }
}

A change is sent to the group of the tenant the request acted in, not the caller's own tenant. The distinction matters in a tree of tenants: a parent's administrator changing a door in a plant below is a change in the plant, and the plant's site is the one that must hear it. A change made with no tenant bound is sent to nobody. Both are unit facts with the hub replaced by a recorder.

The documentation is clear about what groups are: "groups are not a security feature. Authentication claims have features that groups don't, such as expiry and revocation." And "Group membership isn't preserved when a connection reconnects." So the policy and the token decide who hears what; the group is routing, rebuilt on every connect. That is also why the hub has no client-callable methods. It pushes; it never takes.

An admission that holds for fifteen seconds, not for the connection

SignalR "captures the authenticated user when a connection is established and caches it for the lifetime of the connection", and "doesn't automatically revalidate the user during the life of the connection, regardless of the authentication scheme." For a request that lives a few hundred milliseconds, that is fine. For a connection that lives for days, it means a client disabled at noon is still admitted at midnight.

The framework offers CloseOnAuthenticationExpiration, off by default, which closes a connection when its token expires. It stays off here, for two reasons. Reading the connection manager in the 10.0 branch, it compares the token's expiry with DateTimeOffset.UtcNow, a second clock the host cannot set, so no test can make a token expire without waiting on the wall clock. And expiry is only one reason to refuse a connection: a client that has been disabled, or moved to another tenant, holds a token that is still valid and still wrong.

So the host keeps its own list of the connections it admitted, and a background service judges them every fifteen seconds with the same check a request's token gets: the client's standing in the directory's projection, plus the token's exp claim read against the host's clock. A connection refused either way is closed with Abort(). A check that faults closes the connection as well, the same way a request whose token could not be checked is answered 503 (the third note of the identity series), and the sweep goes on to the next connection; OWASP ASVS 5.0.0 asks for exactly this, "preventing fail-open conditions" when an exception occurs (16.5.3). With the directory feed polled every thirty seconds, a revocation reaches an open connection within about a minute, which is the budget the whole platform keeps.

.NET 11 adds authentication refresh to SignalR, which lets a client replace the credentials of an open connection. That changes the expiry half of this story and none of the revocation half; the sweep stays.

A server-side Abort is final for the client

This is the part of the framework that decided the client's shape. When a connection ends, the hub connection handler sends a close message carrying the connection's AllowReconnect flag. The flag starts true, and the framework's own closes (a failed write, the authentication-expiration close) go through AbortAllowReconnect and leave it true. Context.Abort() sets it false. On the client, the .NET HubConnection reads the flag and sets itself to stopping; the close message's documentation says it plainly, "clients with automatic reconnects enabled should not attempt to automatically reconnect after receiving the CloseMessage". Two more facts from the client documentation: WithAutomaticReconnect "doesn't configure the HubConnection to retry initial start failures", and its default policy waits 0, 2, 10 and 30 seconds and "stops after four failed attempts".

So the site keeps the connection itself:

internal static async Task KeepConnectedAsync(
    Func<CancellationToken, Task<Task>> start, TimeSpan retryDelay, ILogger logger, CancellationToken stopping)
{
    try
    {
        while (!stopping.IsCancellationRequested)
        {
            try
            {
                var closedForGood = await start(stopping);   // StartAsync, then a task that completes on Closed
                await closedForGood.WaitAsync(stopping);
                logger.LogClosedForGood(retryDelay);
            }
            catch (Exception exception) when (!stopping.IsCancellationRequested)
            {
                logger.LogStartFailed(exception, retryDelay); // a start that fails is tried again the same way
            }

            await Task.Delay(retryDelay, stopping);
        }
    }
    catch (OperationCanceledException) when (stopping.IsCancellationRequested)
    {
        // The site stops.
    }
}

The loop starts the connection, holds it until the Closed event fires for good, waits thirty seconds, and starts it again, presenting the token the site holds then, and the certificate it holds then. A start that fails is retried the same way. Automatic reconnect stays on for network blips, with an exponential policy that waits 1, 2, 4, 8 and 16 seconds and then 30 between attempts. Polling runs throughout, so the thirty-second hole after a server-side close costs latency and nothing else. The loop ends only when the site stops, and a held connection is then ended without a fault.

The tests for the loop drive it with scripted starts: a connection the cloud closes for good is started again; a start that fails is tried again; stopping the site ends a held connection without a fault.

The connection carries the site's certificate

The site's token is bound to its certificate, as the identity series describes, so the hub connection presents that certificate on every request the connection makes: the negotiation, a long poll and the web socket alike, through the connection's message handler factory and its web socket options. The cloud API's listener for client certificates is the one the hub is reached on, so a token taken from a site's memory opens no connection without the site's key.

What it cost

  • A registry of open connections and a sweep: one dependency-injection scope per sweep and one standing check per connection every fifteen seconds, a cost that grows with the number of sites.
  • A thirty-second gap between a server-side close and the next start. Polling covers it.
  • A clock discipline: the host's clock everywhere, so the framework's expiry option is left off and the reason is written next to the hub's mapping.
  • A hub people cannot use. A browser client would need a hub of its own, with a different policy and a different notion of a group.

What a team can take from this

Decide who may connect with a policy, not with a role check inside the hub; decide what they hear by the tenant the change happened in, not by the caller; and remember that a group is routing, not authorization. Then judge open connections again on a schedule, with the same check your requests get, against your own clock. And read what your server's Abort() tells the client: it tells it to stop, so the client has to own its own loop, and your tests have to drive that loop with a connection that closes at once.

DD

Dejan Demonjić

Principal software engineer, Belgrade

Designs and builds secure, multi-tenant .NET platforms. Questions about this entry, or a different view on it, are welcome by email.

  • Email
  • LinkedIn
  • GitHub
Previous Talking to door controllers: what ISAPI teaches about typed outcomesA boolean cannot carry what an access-control panel answers. Three measured facts (a failed write with HTTP 200 and no status, an acknowledged write that changed nothing, a reused digest nonce the panel refused) turned a 27-member interface whose every write returned a boolean into a closed set of typed outcomes, a classification order, a read-back rule and a fresh challenge per request; asking which way a door fails added a deadline on anything that unlocks one.Next One identity server for the cloud and for every siteWhen the same product runs in the cloud and at customer sites, identity is part of the product, so one identity host on OpenIddict and ASP.NET Core Identity serves both. The decision record: the model, the values, the options rejected, and what it costs.

In this entry

  1. The claim
  2. Context
  3. Who may connect, and what they hear
  4. An admission that holds for fifteen seconds, not for the connection
  5. A server-side Abort is final for the client
  6. The connection carries the site's certificate
  7. What it cost
  8. What a team can take from this
Dejan Demonjić

Designing and building secure, multi-tenant .NET platforms, and writing down the decisions behind them.

Read

  • Decision log
  • Work
  • RSS feed

Contact

  • dejan@demonjic.com
  • Write to me
  • About

Elsewhere

© Dejan Demonjić. Written in Belgrade.