Sending SignalR Messages to a Specific User in ASP.NET Core with IUserIdProvider

Search for a command to run...

No comments yet. Be the first to comment.
The prompt is the leak. Every other control in an AI feature gets scrutinised - authentication on the endpoint, authorization on the tools, validation of the model's output - while the one thing that

Most of the .NET conversation about the Model Context Protocol is about building servers: expose your API as MCP tools, point Claude or VS Code at it, done. That is the half that gets written about. T

The first RAG system I shipped answered beautifully about concepts and failed completely on part numbers. Ask it "how do I reset a stuck deployment" and it nailed the answer. Ask it "what does error P

AutoMapper has been the default object mapper in .NET for over a decade, and for most of that time nobody thought about it. Version 15.0.0 changed that. It ships under a dual license now, with a free

Coding Droplets
306 posts
Coding Droplets is your go-to resource for .NET and ASP.NET Core development. Whether you're just starting out or building production systems, you'll find practical guides, real-world patterns, and clear explanations that actually make sense.
From beginner-friendly tutorials to advanced architecture decisions. We publish fresh .NET content every day to help you grow at every stage of your career.
The requirement sounds trivial until you try it. A background job finishes and exactly one person needs to know, on whichever devices they happen to have open. The first instinct is to store connection ids in a dictionary keyed by user, and that dictionary becomes a bug factory the moment a user opens a second tab or a connection drops and reconnects with a new id. Using IUserIdProvider in SignalR to send a message to a specific user is the built-in answer, and it already handles the multi-connection and reconnection cases that a hand-rolled map gets wrong.
I've replaced that hand-rolled dictionary in more than one production codebase, and the replacement is always smaller than what it deletes. If you want the finished version with the notification service, the reconnection handling, and the integration tests wired together, the complete implementation is on Patreon rather than scattered across snippets.
A connection id identifies a transport. A user identifies a person. These are not the same thing, and conflating them causes three specific failures:
Multiple devices. A user on a laptop and a phone has two connections. Sending to "the" connection id reaches one of them.
Reconnection. Connection ids change on every reconnect. A stored id goes stale silently, and the failure looks like "notifications sometimes do not arrive".
Scale-out. With more than one server, the connection you want is often not on the server holding the request.
SignalR already solves all three. Clients.User(userId) fans out to every live connection belonging to that user, on any server, as long as you tell SignalR what a user id is.
SignalR resolves a user id through IUserIdProvider. The default implementation reads the ClaimTypes.NameIdentifier claim from the authenticated principal on the connection, and whatever string it returns becomes the key used by Clients.User(...).
That is the whole contract, and it is one method:
public class TenantUserIdProvider : IUserIdProvider
{
public string? GetUserId(HubConnectionContext connection)
{
var tenant = connection.User?.FindFirst("tenant_id")?.Value;
var user = connection.User?.FindFirst(ClaimTypes.NameIdentifier)?.Value;
return tenant is null || user is null ? null : $"{tenant}:{user}";
}
}
Register it as a singleton and the framework uses it for every connection:
builder.Services.AddSingleton<IUserIdProvider, TenantUserIdProvider>();
The tenant prefix in that example is not decoration. If user ids are only unique within a tenant, an unprefixed provider will happily deliver one tenant's notification to a different tenant's user with the same local id. Microsoft's SignalR users and groups documentation covers the base behaviour; the partitioning is on you.
This one costs teams entire afternoons, so it deserves its own section.
Modern JWT setups frequently set MapInboundClaims = false on the bearer options, which is generally good practice: it stops the handler rewriting standard JWT claim names into the legacy long-form Microsoft claim URIs. But the default IUserIdProvider looks for ClaimTypes.NameIdentifier, which is one of those long-form URIs. With inbound mapping disabled, the token's sub claim stays as sub, the lookup finds nothing, and GetUserId returns null.
The symptom is brutal in its subtlety: everything connects, [Authorize] passes, the hub method runs, and Clients.User(...) throws no error. Messages simply go nowhere.
The fix is to look for the claim you actually have:
// Works whether or not inbound claim mapping is enabled
var userId = connection.User?.FindFirst("sub")?.Value
?? connection.User?.FindFirst(ClaimTypes.NameIdentifier)?.Value;
Whenever "SignalR user targeting does not work but everything else does", check this first.
Browsers cannot set custom headers on a WebSocket handshake, so the standard bearer header does not survive the upgrade. SignalR clients send the token as an access_token query string parameter instead, and your JWT options need to read it:
options.Events = new JwtBearerEvents
{
OnMessageReceived = context =>
{
var token = context.Request.Query["access_token"];
var path = context.HttpContext.Request.Path;
if (!string.IsNullOrEmpty(token) && path.StartsWithSegments("/hubs"))
context.Token = token;
return Task.CompletedTask;
}
};
This is the pattern Microsoft documents for authentication and authorization in SignalR. Two things follow from it. Restrict the path check to your hub routes so ordinary API endpoints keep requiring the header. And be aware that tokens in query strings can land in server access logs, so keep hub token lifetimes short. Our list of common JWT authentication mistakes in ASP.NET Core covers the surrounding configuration worth checking while you are in this file.
Most real notifications originate in a background service or a message handler, not in a hub method. Inject IHubContext<THub> rather than trying to reach a hub instance, which is transient and not something you should hold:
await _hubContext.Clients
.User($"{tenantId}:{userId}")
.SendAsync("OrderShipped", payload, ct);
The user id string must be produced by exactly the same logic as your IUserIdProvider. Put that formatting in one shared method rather than composing the string at each call site, because a mismatch produces the same silent no-op as the claim problem above.
Both, for different jobs. The distinction is worth being deliberate about:
| Need | Use |
|---|---|
| Notify one person wherever they are | Clients.User(userId) |
| Notify everyone watching a document | Group per document |
| Notify everyone in an organisation | Group per tenant |
| Reply to the caller of a hub method | Clients.Caller |
Groups need explicit management: add on connect, and re-add on reconnect, because group membership does not survive a new connection. User targeting needs none of that, which is precisely why it is the better default when identity is what you are addressing.
You need a backplane above one server. With multiple instances, Clients.User(...) only reaches connections on the current server unless you add the Redis backplane or move to Azure SignalR Service. This is the most common reason it "works locally and not in production". Our comparison of self-hosted SignalR vs Azure SignalR Service vs Azure Web PubSub covers that decision.
Delivery is best effort. If the user has no live connection, the message is dropped, not queued. Anything that must survive an offline user needs to be persisted and replayed on connect. Treat real-time delivery as an accelerator over durable state, never as the state itself.
Transport fallback changes behaviour. When WebSockets are unavailable the client falls back to long polling, which affects latency and connection churn. We covered the diagnostics for that in SignalR falls back to long polling.
Anonymous connections have no user id. GetUserId returning null is legitimate; those connections are simply unreachable by user targeting. Decide explicitly whether anonymous connections are allowed on the hub at all.
Add one integration test that connects two clients as the same user and asserts both receive a message sent with Clients.User(...). It takes minutes, and it fails loudly the day someone changes claim mapping, adds a tenant prefix on one side only, or deploys a second replica without a backplane. That single test covers every failure mode described above.
The overwhelmingly common cause is that IUserIdProvider returned null, usually because the claim it looks for is absent. With MapInboundClaims = false, the JWT sub claim never becomes ClaimTypes.NameIdentifier, so the default provider finds nothing. The second most common cause is a user id string that does not match what the provider generates. Neither raises an error, so log the resolved user id on connect.
Yes. SignalR tracks every connection associated with a user id and delivers to all of them, which is exactly why user targeting is preferable to storing connection ids. A user with a phone and two browser tabs receives the message three times, once per live connection.
Inject IHubContext<THub> and call Clients.User(userId).SendAsync(...). Do not attempt to resolve or cache a hub instance: hubs are transient and only valid for the duration of a single method invocation. IHubContext is the supported way to reach connected clients from anywhere in the application.
Only when you run more than one server instance. On a single instance SignalR holds all connection state in memory. Scale out and each instance only knows its own connections, so a message sent from instance A never reaches a user connected to instance B without a backplane or Azure SignalR Service.
Compose the user id from both values in your IUserIdProvider, for example tenantId:userId, and use the identical format everywhere you call Clients.User(...). Without the prefix, two tenants that both have a user with local id 1 will receive each other's notifications, which is a cross-tenant data leak rather than a cosmetic bug.
The message is discarded. SignalR has no store-and-forward semantics. If the notification matters, persist it first and have the client fetch anything it missed when it connects. Sending only over SignalR guarantees that anyone who was briefly disconnected never learns what happened.
I'm Celin Daniel, Co-founder of Coding Droplets. I've been building .NET and ASP.NET Core systems in production for 13+ years - APIs, distributed backends, enterprise platforms. Everything I write here comes from real shipping experience: patterns that held up, trade-offs that bit us, and lessons learned the hard way.
GitHub: codingdroplets
YouTube: Coding Droplets
Website: codingdroplets.com