SignalR Hubs

Push order updates to clients with a strongly typed SignalR hub, target callers, groups and users, and secure the hub with JWT tokens.

Syntaxpublic class OrdersHub : Hub<IOrderClient> { public Task WatchOrder(int id) => Groups.AddToGroupAsync(Context.ConnectionId, $"order-{id}"); } app.MapHub<OrdersHub>("/hubs/orders");

SignalR is an RPC layer over WebSockets, with Server-Sent Events and long polling as fallbacks. A client POSTs to /hubs/orders/negotiate to get a connection token and the allowed transports, opens the transport, sends a handshake ({"protocol":"json","version":1}), and from then on each side invokes methods on the other by name. The server ships with ASP.NET Core: AddSignalR() and app.MapHub<OrdersHub>("/hubs/orders"). JSON is the default protocol; MessagePack is an extra package.

The hub is transient

A hub instance is created for each invocation and for OnConnectedAsync/OnDisconnectedAsync, then disposed. Fields do not survive between calls: per-connection data goes in Context.Items, shared data in a singleton or a store. Context carries ConnectionId, User, UserIdentifier and ConnectionAborted.

Who receives a message

TargetReaches
Clients.CallerThe connection that invoked the method
Clients.Others, AllExcept(ids)Everyone else
Clients.Group("order-2042")Connections added to that group
Clients.User(userId)Every connection of one user: tabs, phone, laptop
Clients.AllEvery connection on this server

Groups are created on first add and vanish when empty. They live in server memory, keyed by connection id, and a reconnect gets a new id, so the client must join again. A group is not a security boundary: check the user may see order 2042 before Groups.AddToGroupAsync.

Clients.User keys on Context.UserIdentifier, the ClaimTypes.NameIdentifier claim by default. With MapInboundClaims = false the token's sub is not mapped to it, UserIdentifier is null and user messages silently reach nobody; register an IUserIdProvider that reads sub.

Strongly typed hubs and IHubContext

Hub<IOrderClient> turns SendAsync("OrderStatusChanged", ...) strings into interface calls, so a renamed client method is a compile error. Most messages start outside a hub, in an endpoint, consumer or BackgroundService: inject IHubContext<OrdersHub, IOrderClient>, a singleton.

Authentication

Put [Authorize] on the hub or its methods. Browsers cannot set headers on WebSocket or EventSource requests, so the JavaScript client sends the JWT as access_token in the query string, which JwtBearerEvents.OnMessageReceived must read for hub paths. Authentication happens once, at connect: the connection outlives the token unless you set CloseOnAuthenticationExpiration. Query strings appear in proxy logs, so keep tokens short-lived. The app logs them too: Microsoft.AspNetCore.Hosting writes the full request URL, token included, at Information level, so raise that category (and HTTP logging) to Warning or redact the query string in production.

Defaults that bite

  • Delivery is at most once and nothing is queued for a disconnected client. Clients re-fetch state over HTTP after a reconnect.
  • Incoming messages are capped at 32 KB (MaximumReceiveMessageSize): send ids, not documents.
  • The server pings every 15 seconds and drops a client silent for 30.
  • One invocation per connection runs at a time, so a slow hub method delays that client's next call.
  • Exceptions reach the client as a generic error; throw HubException to send a message on purpose.

Example

Try it yourself
Loading editor…
Press Run to execute the code.

When to use it

  • Tell every open tab of a customer that order 2042 has shipped the moment the warehouse scan arrives
  • Let support agents watch every order while customers only receive updates for orders they own
  • Push a payment-failed event from a message consumer through IHubContext without the consumer knowing about connections

More examples

JWT authentication, user ids and ownership checks

<code>OnMessageReceived</code> reads the query-string token only for <code>/hubs</code> paths, so ordinary endpoints keep requiring the header. Because <code>MapInboundClaims</code> is off, the <code>SubjectUserIdProvider</code> makes <code>Clients.User("cust-17")</code> work from the raw <code>sub</code> claim. <code>CloseOnAuthenticationExpiration</code> closes the connection when the token expires; the client then reconnects with a fresh token. <code>HubException</code> messages reach the caller verbatim, so they are written for the client.

Example · csharp
using Microsoft.AspNetCore.Authentication.JwtBearer;
using Microsoft.AspNetCore.Authorization;
using Microsoft.AspNetCore.SignalR;

var builder = WebApplication.CreateBuilder(args);

builder.Services.AddAuthentication(JwtBearerDefaults.AuthenticationScheme)
    .AddJwtBearer(options =>
    {
        options.Authority = builder.Configuration["Auth:Authority"];
        options.Audience = "orders-api";
        options.MapInboundClaims = false;
        options.TokenValidationParameters.NameClaimType = "sub";
        options.TokenValidationParameters.RoleClaimType = "role";
        options.Events = new JwtBearerEvents
        {
            OnMessageReceived = context =>
            {
                // Browsers cannot send headers on WebSocket or EventSource requests.
                var accessToken = context.Request.Query["access_token"];
                if (!string.IsNullOrEmpty(accessToken) && context.HttpContext.Request.Path.StartsWithSegments("/hubs"))
                {
                    context.Token = accessToken;
                }
                return Task.CompletedTask;
            }
        };
    });
builder.Services.AddAuthorization();
builder.Services.AddSignalR();
builder.Services.AddSingleton<IUserIdProvider, SubjectUserIdProvider>();
builder.Services.AddSingleton<IOrderOwnership, InMemoryOrderOwnership>();

var app = builder.Build();
app.UseAuthentication();
app.UseAuthorization();
app.MapHub<CustomerOrdersHub>("/hubs/orders", options => options.CloseOnAuthenticationExpiration = true);
app.Run();

public sealed class SubjectUserIdProvider : IUserIdProvider
{
    public string? GetUserId(HubConnectionContext connection) => connection.User?.FindFirst("sub")?.Value;
}

public interface IOrderOwnership
{
    Task<bool> IsOwnerAsync(string customerId, int orderId, CancellationToken cancellationToken);
}

public sealed class InMemoryOrderOwnership : IOrderOwnership
{
    private readonly Dictionary<int, string> owners = new() { [2042] = "cust-17", [2043] = "cust-23" };

    public Task<bool> IsOwnerAsync(string customerId, int orderId, CancellationToken cancellationToken) =>
        Task.FromResult(owners.TryGetValue(orderId, out var owner) && owner == customerId);
}

[Authorize]
public sealed class CustomerOrdersHub(IOrderOwnership ownership) : Hub
{
    public async Task WatchOrder(int orderId)
    {
        var customerId = Context.UserIdentifier ?? throw new HubException("No user id in the token.");
        if (!await ownership.IsOwnerAsync(customerId, orderId, Context.ConnectionAborted))
        {
            // Same message for "not yours" and "does not exist", so ids cannot be probed.
            throw new HubException($"Order {orderId} was not found.");
        }
        await Groups.AddToGroupAsync(Context.ConnectionId, $"order-{orderId}");
    }

    [Authorize(Roles = "support")]
    public Task WatchAllOrders() => Groups.AddToGroupAsync(Context.ConnectionId, "orders-all");
}

A .NET client that rejoins its groups

Microsoft.AspNetCore.SignalR.Client is the .NET client, used by workers, desktop apps and integration tests. <code>AccessTokenProvider</code> is called on every connect and reconnect, so it can return a refreshed token. <code>WithAutomaticReconnect()</code> retries after 0, 2, 10 and 30 seconds, then raises <code>Closed</code>. The <code>Reconnected</code> handler is where the client restores its groups and catches up, because the server neither remembers group membership across connections nor queues missed messages.

Example · csharp
using Microsoft.AspNetCore.SignalR.Client;

await using var connection = new HubConnectionBuilder()
    .WithUrl("https://orders.example.com/hubs/orders", options =>
    {
        options.AccessTokenProvider = () => Task.FromResult(Environment.GetEnvironmentVariable("ORDERS_API_TOKEN"));
    })
    .WithAutomaticReconnect()
    .Build();

connection.On<OrderStatusUpdate>("OrderStatusChanged", update =>
    Console.WriteLine($"Order {update.OrderId} is now {update.Status} ({update.ChangedAt:u})"));

// A reconnect is a new connection id with no groups, and anything sent meanwhile is gone.
connection.Reconnected += async connectionId =>
{
    await connection.InvokeAsync("WatchOrder", 2042);
    Console.WriteLine("Reconnected; re-fetch order 2042 over HTTP to catch up on missed updates.");
};

connection.Closed += error =>
{
    Console.WriteLine($"Connection closed for good: {error?.Message ?? "stopped"}");
    return Task.CompletedTask;
};

await connection.StartAsync();
await connection.InvokeAsync("WatchOrder", 2042);
await Task.Delay(TimeSpan.FromMinutes(5));

public sealed record OrderStatusUpdate(int OrderId, string Status, DateTimeOffset ChangedAt);

A hub filter for logging and safe errors

<code>IHubFilter</code> is the hub's middleware: it wraps every invocation, and also <code>OnConnectedAsync</code> and <code>OnDisconnectedAsync</code> if you implement those members. Filters added in <code>AddSignalR</code> apply to all hubs; <code>AddHubOptions&lt;THub&gt;</code> scopes them to one. Registering the filter as a singleton and adding it by type means one instance serves every call. Rethrowing as <code>HubException</code> gives clients a stable message while the log keeps the real exception.

Example · csharp
using System.Diagnostics;
using Microsoft.AspNetCore.SignalR;

var builder = WebApplication.CreateBuilder(args);
builder.Services.AddSingleton<HubTimingFilter>();
builder.Services.AddSignalR(options =>
{
    options.AddFilter<HubTimingFilter>();
    options.EnableDetailedErrors = builder.Environment.IsDevelopment();
});

var app = builder.Build();
app.MapHub<InvoicesHub>("/hubs/invoices");
app.Run();

public sealed class InvoicesHub : Hub
{
    public Task WatchInvoice(string invoiceNumber) =>
        Groups.AddToGroupAsync(Context.ConnectionId, $"invoice-{invoiceNumber}");
}

public sealed class HubTimingFilter(ILogger<HubTimingFilter> logger) : IHubFilter
{
    public async ValueTask<object?> InvokeMethodAsync(
        HubInvocationContext invocationContext, Func<HubInvocationContext, ValueTask<object?>> next)
    {
        var started = Stopwatch.GetTimestamp();
        try
        {
            return await next(invocationContext);
        }
        catch (Exception ex) when (ex is not HubException)
        {
            logger.LogError(ex, "Hub method {Method} failed for connection {ConnectionId}",
                invocationContext.HubMethodName, invocationContext.Context.ConnectionId);
            throw new HubException("The request could not be completed.");
        }
        finally
        {
            logger.LogInformation("Hub method {Method} took {ElapsedMs} ms",
                invocationContext.HubMethodName, Stopwatch.GetElapsedTime(started).TotalMilliseconds);
        }
    }
}

Discussion

  • Be the first to comment on this lesson.