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.
public 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
| Target | Reaches |
|---|---|
Clients.Caller | The 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.All | Every 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
HubExceptionto send a message on purpose.
Example
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.
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.
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<THub></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.
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