Hub Authentication — JWT Bearer and User-Specific Channels

Securing SignalR Hubs requires special configuration because WebSocket connections cannot send custom HTTP headers like a normal HTTP request. Instead, the JWT token is passed as a query string parameter on the WebSocket upgrade URL, and ASP.NET Core’s JWT Bearer middleware is configured to read it from there. Once authenticated, the Hub has access to Context.User with the full claims from the JWT — enabling user-specific groups, targeted notifications, and role-based Hub method access.

JWT Authentication for SignalR

// ── Configure JWT Bearer to read token from query string for SignalR ──────
builder.Services
    .AddAuthentication(JwtBearerDefaults.AuthenticationScheme)
    .AddJwtBearer(options =>
    {
        options.TokenValidationParameters = new TokenValidationParameters
        {
            ValidateIssuerSigningKey = true,
            IssuerSigningKey         = new SymmetricSecurityKey(
                Encoding.UTF8.GetBytes(jwtSettings["SecretKey"]!)),
            ValidateIssuer   = true, ValidIssuer   = jwtSettings["Issuer"],
            ValidateAudience = true, ValidAudience = jwtSettings["Audience"],
            ValidateLifetime = true, ClockSkew     = TimeSpan.Zero,
        };

        // ── SignalR-specific: read token from query string ────────────────
        // WebSockets cannot set Authorization header — token must be in URL
        options.Events = new JwtBearerEvents
        {
            OnMessageReceived = context =>
            {
                var accessToken = context.Request.Query["access_token"];
                var path        = context.HttpContext.Request.Path;

                // Only read from query string for Hub endpoints
                if (!string.IsNullOrEmpty(accessToken) &&
                    path.StartsWithSegments("/hubs"))
                {
                    context.Token = accessToken;
                }
                return Task.CompletedTask;
            }
        };
    });

// ── Protected Hub ─────────────────────────────────────────────────────────
[Authorize]   // requires valid JWT for the entire hub
public class UserNotificationHub : Hub
{
    public override async Task OnConnectedAsync()
    {
        var userId = Context.User!.FindFirstValue(ClaimTypes.NameIdentifier)!;

        // Add to user-specific group on connect
        await Groups.AddToGroupAsync(Context.ConnectionId, $"user-{userId}");

        // Add to role-based groups
        if (Context.User.IsInRole("Admin"))
            await Groups.AddToGroupAsync(Context.ConnectionId, "admins");

        await base.OnConnectedAsync();
    }

    public override async Task OnDisconnectedAsync(Exception? exception)
    {
        var userId = Context.User?.FindFirstValue(ClaimTypes.NameIdentifier);
        if (userId is not null)
            await Groups.RemoveFromGroupAsync(Context.ConnectionId, $"user-{userId}");
        await base.OnDisconnectedAsync(exception);
    }

    // ── Admin-only Hub method ─────────────────────────────────────────────
    [Authorize(Roles = "Admin")]
    public async Task BroadcastSystemAlert(string message)
        => await Clients.All.SendAsync("SystemAlert", message);
}

// ── Send to specific user from a service ──────────────────────────────────
public async Task NotifyUserAsync(string userId, string message, CancellationToken ct)
{
    // Clients.User() sends to ALL connections for this user (multiple tabs/devices)
    await _hubContext.Clients.User(userId).SendAsync("Notification", new
    {
        Message   = message,
        Timestamp = DateTime.UtcNow,
    }, ct);
}
Note: Clients.User(userId) sends to all active connections for a specific user — if the user is logged in on three browser tabs, all three receive the message. This works because SignalR maps the ClaimTypes.NameIdentifier claim to the user’s connections. The IUserIdProvider interface (registered in DI) controls how the user ID is extracted from Context.User — the default uses ClaimTypes.NameIdentifier which matches what the JWT token service sets as the sub claim.
Tip: On the Angular side, pass the JWT access token as a query parameter in the SignalR connection builder: .withUrl(`${environment.apiUrl}/hubs/notifications?access_token=${token}`). The token is included in the WebSocket upgrade URL, intercepted by the JWT Bearer middleware, and validated. Note that tokens in URLs can appear in server access logs — use HTTPS to ensure the URL (and therefore the token) is encrypted in transit. For extra security, use short-lived access tokens (15 minutes) for SignalR connections.
Warning: Groups are stored in memory by default. When a user disconnects and reconnects (page refresh, network interruption), their connection gets a new connection ID and is no longer in the groups they previously joined. Your OnConnectedAsync must re-add the user to all their required groups on each connection. This is why user-specific groups based on user ID (set in OnConnectedAsync) are reliable — the user ID is always available from the JWT on reconnection. Topic-based groups (post subscriptions) must be re-established by the Angular client after reconnection.

Common Mistakes

Mistake 1 — Not configuring OnMessageReceived to read token from query string (401 on WebSocket)

❌ Wrong — JWT Bearer only checks Authorization header; WebSocket connections cannot set headers; all connections return 401.

✅ Correct — configure OnMessageReceived to read from context.Request.Query["access_token"] for hub paths.

Mistake 2 — Not re-adding users to groups on reconnection (missing notifications after reconnect)

❌ Wrong — groups are in-memory; after disconnect/reconnect the user is no longer in any group.

✅ Correct — always re-add to required groups in OnConnectedAsync using claims from the JWT.

🧠 Test Yourself

An Angular client connects to an authenticated SignalR Hub. The Hub receives the connection but Context.User is null (anonymous). The JWT is valid for REST API calls. What is the missing configuration?