Authentication and Streaming
Require a signed-in caller with [Authorize], read their claims from Context.User, and return a stream instead of a single reply.
public async IAsyncEnumerable<int> Countdown(int from, [EnumeratorCancellation] CancellationToken cancellationToken)By default any connection may call every method on a hub, signed in or not. Put [Authorize] on the hub class to require a signed-in caller, or on a single method to protect only that method. The requirement on the class is checked when the connection is established; a method's own attribute is checked on each call of it.
The request pipeline still needs app.UseAuthentication() and app.UseAuthorization(). Inside a hub method, Context.User is the caller's ClaimsPrincipal, so Context.User?.FindFirst(ClaimTypes.Name)?.Value reads one claim out of it. Context.UserIdentifier is the id that Clients.User matches on.
Getting the token in
A browser cannot set an Authorization header on a WebSocket. SignalR therefore sends the token in the query string as access_token. The JWT bearer handler reads only the header, so you have to copy it across in the handler's OnMessageReceived event. Miss that and every hub connection is rejected while your ordinary endpoints keep working.
Streaming
A hub method that returns IAsyncEnumerable<T>, ChannelReader<T>, or a Task of either becomes a stream. Each item reaches the client as it is produced, instead of one reply at the end. The simplest form is an async iterator method that takes a CancellationToken marked [EnumeratorCancellation]. SignalR cancels that token when the client unsubscribes or disconnects, so check it and pass it to anything you call. A method that accepts IAsyncEnumerable<T> or ChannelReader<T> as a parameter streams the other way, from client to server.
Example
using System.Runtime.CompilerServices;
using System.Security.Claims;
using Microsoft.AspNetCore.Authorization;
using Microsoft.AspNetCore.SignalR;
var builder = WebApplication.CreateBuilder(args);
builder.Services.AddSignalR();
builder.Services.AddAuthentication("Bearer").AddJwtBearer();
builder.Services.AddAuthorization();
var app = builder.Build();
app.UseAuthentication(); // reads the token and fills Context.User
app.UseAuthorization(); // enforces [Authorize]
app.MapHub<TickerHub>("/hubs/ticker");
app.Run();
[Authorize] // every method needs a signed-in caller
public class TickerHub : Hub
{
// The caller's own name, read out of the token's claims.
public string WhoAmI()
=> Context.User?.FindFirst(ClaimTypes.Name)?.Value ?? "(no name claim)";
// Returning IAsyncEnumerable<T> makes this a stream, not a single reply.
public async IAsyncEnumerable<int> Countdown(
int from,
[EnumeratorCancellation] CancellationToken cancellationToken)
{
for (int i = from; i > 0; i--)
{
yield return i;
await Task.Delay(500, cancellationToken);
}
}
}When to use it
- Require a signed-in user before any client can connect to a notifications hub.
- Restrict a "remove this connection" hub method to callers in the admin role.
- Stream log lines to a browser as they are written, stopping the work when the page closes.
- Accept a large file from a desktop client as a stream of chunks rather than one argument.
More examples
Letting the JWT handler see the query-string token
This is the piece almost everyone leaves out, and the failure looks like a broken hub rather than a missing token. On the browser side, the JavaScript client is told where to get the token with withUrl("/hubs/ticker", { accessTokenFactory: () => token }). Restricting the path check to /hubs matters: a token in a query string can end up in server logs and browser history, so do not accept one on your whole site.
using Microsoft.AspNetCore.Authentication.JwtBearer;
builder.Services.AddAuthentication(JwtBearerDefaults.AuthenticationScheme)
.AddJwtBearer(options =>
{
options.Events = new JwtBearerEvents
{
OnMessageReceived = context =>
{
// A browser cannot set headers on a WebSocket, so the
// JavaScript client puts the token in the query string.
string? token = context.Request.Query["access_token"];
// Only trust it on the hub paths, never site-wide.
if (!string.IsNullOrEmpty(token) &&
context.HttpContext.Request.Path.StartsWithSegments("/hubs"))
{
context.Token = token;
}
return Task.CompletedTask;
}
};
});A policy on one method only
Two levels of check: the class attribute gates the connection, the method attribute gates the call. A caller who fails the method policy gets an error back for that one invocation and stays connected, which is usually what you want for an admin action inside an otherwise ordinary page. Policies are the same ones the rest of the app uses; nothing about them is SignalR-specific.
// In Program.cs:
// builder.Services.AddAuthorization(options =>
// options.AddPolicy("Admins", policy => policy.RequireRole("admin")));
[Authorize] // any signed-in caller may connect
public class ModerationHub : Hub
{
public Task Ping()
=> Clients.Caller.SendAsync("Pong");
[Authorize("Admins")] // and this one also needs the admin role
public Task Disconnect(string connectionId)
=> Clients.Client(connectionId).SendAsync("Removed");
}Streaming from client to server
Use this instead of one giant method argument when the client produces data over time, such as a log tail or a file read in chunks. The two parameter types are interchangeable from the client's point of view; the IAsyncEnumerable version is shorter to write, and the ChannelReader version gives you the pair of loops that let you drain everything already buffered before waiting again. The server-side buffer per stream holds ten items by default, set by StreamBufferCapacity.
using System.Threading.Channels;
using Microsoft.AspNetCore.SignalR;
public class UploadHub : Hub
{
// An IAsyncEnumerable parameter: the client pushes lines over time.
public async Task<int> UploadLines(IAsyncEnumerable<string> lines)
{
int total = 0;
await foreach (string line in lines)
{
total += line.Length;
}
// Sent back once the client completes its stream.
return total;
}
// The ChannelReader form, drained by hand.
public async Task UploadChannel(ChannelReader<string> lines)
{
while (await lines.WaitToReadAsync())
{
while (lines.TryRead(out string? line))
{
Console.WriteLine(line);
}
}
}
}
Discussion