Authentication and Authorisation in SignalR

Securing SignalR connections isn't quite the same as securing REST endpoints. WebSocket connections can't send custom headers after the initial handshake, which means the standard Authorization: Bearer <token> approach needs adaptation. Let's walk through the options.

The WebSocket Authentication Challenge

HTTP requests carry headers on every call. WebSockets don't — they upgrade from HTTP once, then communicate over a persistent connection. This means the token must be provided during the initial handshake.

SignalR handles this by accepting the token as a query string parameter during connection negotiation. The server needs to be configured to extract it.

Setting Up JWT Authentication

First, configure authentication in Program.cs:

Program.cs
builder.Services.AddAuthentication(JwtBearerDefaults.AuthenticationScheme)
    .AddJwtBearer(options =>
    {
        options.TokenValidationParameters = new TokenValidationParameters
        {
            ValidateIssuer = true,
            ValidateAudience = true,
            ValidateLifetimeInMinutes = true,
            ValidIssuer = builder.Configuration["Jwt:Issuer"],
            ValidAudience = builder.Configuration["Jwt:Audience"],
            IssuerSigningKey = new SymmetricSecurityKey(
                Encoding.UTF8.GetBytes(builder.Configuration["Jwt:Key"]!))
        };

        // Extract token from query string for SignalR
        options.Events = new JwtBearerEvents
        {
            OnMessageReceived = context =>
            {
                var accessToken = context.Request.Query["access_token"];
                var path = context.HttpContext.Request.Path;

                if (!string.IsNullOrEmpty(accessToken)
                    && path.StartsWithSegments("/hubs"))
                {
                    context.Token = accessToken;
                }

                return Task.CompletedTask;
            }
        };
    });

The key part is OnMessageReceived. When SignalR initiates its WebSocket connection to any path under /hubs, the token is pulled from the query string instead of the Authorization header.

Client-Side Token Delivery

On the JavaScript client, pass the token via the accessTokenFactory:

script.js
const connection = new signalR.HubConnectionBuilder()
    .withUrl("/hubs/chat", {
        accessTokenFactory: () => {
            return localStorage.getItem("jwt_token");
        }
    })
    .withAutomaticReconnect()
    .build();

The factory is called on every connection attempt, including reconnections. This means you can return a refreshed token if the original has expired.

For the .NET client:

Example.cs
var connection = new HubConnectionBuilder()
    .WithUrl("https://example.com/hubs/chat", options =>
    {
        options.AccessTokenProvider = async () =>
        {
            var token = await _tokenService.GetTokenAsync();
            return token;
        };
    })
    .WithAutomaticReconnect()
    .Build();

If your application uses cookie authentication (common with server-rendered apps or Blazor Server), cookies are sent automatically during the WebSocket handshake. No additional configuration is needed:

Example.cs
builder.Services.AddAuthentication(CookieAuthenticationDefaults.AuthenticationScheme)
    .AddCookie();

Cookies just work with SignalR because the browser includes them in the HTTP upgrade request.

Authorising Hub Access

Use the [Authorize] attribute on the hub class or individual methods:

Example.cs
[Authorize]
public class AdminHub : Hub<IAdminClient>
{
    // All methods require authentication

    [Authorize(Roles = "Admin")]
    public async Task ShutdownServer(string reason)
    {
        await Clients.All.ServerShuttingDown(reason);
    }

    [Authorize(Policy = "CanManageUsers")]
    public async Task BanUser(string userId)
    {
        // Only users matching the policy can call this
        await Clients.User(userId).Disconnected("You have been banned.");
    }
}

You can also apply authorisation at the endpoint level:

Example.cs
app.MapHub<AdminHub>("/hubs/admin")
    .RequireAuthorization("AdminPolicy");

Accessing User Identity

Inside a hub, Context.User gives you the ClaimsPrincipal, and Context.UserIdentifier returns the user's identifier (by default, the NameIdentifier claim):

Example.cs
public class ChatHub : Hub<IChatClient>
{
    public async Task SendMessage(string message)
    {
        var userName = Context.User?.Identity?.Name
            ?? throw new HubException("Not authenticated.");

        await Clients.Others.ReceiveMessage(userName, message);
    }
}

To customise which claim is used for UserIdentifier, implement IUserIdProvider:

Example.cs
public class EmailUserIdProvider : IUserIdProvider
{
    public string? GetUserId(HubConnectionContext connection)
    {
        return connection.User?.FindFirst(ClaimTypes.Email)?.Value;
    }
}

// Register it
builder.Services.AddSingleton<IUserIdProvider, EmailUserIdProvider>();

This affects how Clients.User(userId) resolves connections — it maps the identifier to all connections for that user.

Security Considerations

Token in query strings. The access token appears in the URL, which means it can be logged by proxies, load balancers, and web servers. Mitigate this by using short-lived tokens and ensuring TLS everywhere.

CORS. If your client is on a different origin, configure CORS to allow the SignalR endpoint:

Example.cs
builder.Services.AddCors(options =>
{
    options.AddPolicy("SignalR", policy =>
    {
        policy.WithOrigins("https://app.example.com")
            .AllowAnyHeader()
            .AllowAnyMethod()
            .AllowCredentials();
    });
});

AllowCredentials() is required for cookies. You cannot combine it with AllowAnyOrigin() — you must specify explicit origins.

Connection lifetime. A token can expire while a WebSocket connection is still open. SignalR does not re-validate the token on every message. If you need stricter control, implement a hub filter or check token expiry in hub methods.

Key Takeaways