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:
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:
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:
var connection = new HubConnectionBuilder()
.WithUrl("https://example.com/hubs/chat", options =>
{
options.AccessTokenProvider = async () =>
{
var token = await _tokenService.GetTokenAsync();
return token;
};
})
.WithAutomaticReconnect()
.Build();
Cookie Authentication
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:
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:
[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:
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):
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:
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:
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
- Use
OnMessageReceivedto extract JWT tokens from the query string for WebSocket connections. - Cookie auth works out of the box — no special handling needed.
- Apply
[Authorize]at the hub or method level for granular access control. - Customise user identification with
IUserIdProvider. - Be aware that query string tokens are visible in logs — use short-lived tokens and TLS.