SignalR Authentication and Authorization
Secure SignalR hubs with JWT authentication: token delivery via query string, hub-level and method-level authorization, the WebSocket JWT challenge problem, and production auth patterns.
The WebSocket JWT Challenge
Standard HTTP requests carry JWT in the Authorization: Bearer {token} header. WebSockets do not support custom headers during the handshake. SignalR's solution: pass the token in the query string during negotiation.
Normal HTTP:
GET /api/patients
Authorization: Bearer eyJhbGci...
SignalR WebSocket connection:
WebSocket Upgrade Request → cannot add custom headers
Solution: GET /hubs/clinical?access_token=eyJhbGci...JWT Configuration for SignalR
// Program.cs — tell JwtBearer to read the token from the query string for hub endpoints
builder.Services.AddAuthentication(JwtBearerDefaults.AuthenticationScheme)
.AddJwtBearer(options =>
{
options.TokenValidationParameters = new TokenValidationParameters
{
ValidateIssuer = true,
ValidateAudience = true,
ValidateLifetime = true,
ValidateIssuerSigningKey = true,
ValidIssuer = builder.Configuration["Jwt:Issuer"],
ValidAudience = builder.Configuration["Jwt:Audience"],
IssuerSigningKey = new SymmetricSecurityKey(
Encoding.UTF8.GetBytes(builder.Configuration["Jwt:Key"]!)),
};
// For SignalR WebSocket connections — token in query string
options.Events = new JwtBearerEvents
{
OnMessageReceived = context =>
{
var token = context.Request.Query["access_token"];
// Only apply to SignalR hub paths
var path = context.HttpContext.Request.Path;
if (!string.IsNullOrEmpty(token) &&
path.StartsWithSegments("/hubs"))
{
context.Token = token;
}
return Task.CompletedTask;
}
};
});Hub Authorization
// Hub-level: all methods require authentication
[Authorize]
public sealed class ClinicalDashboardHub : Hub<IClinicalDashboardClient>
{
// All hub methods require an authenticated user
// Method-level: override with stricter policy
[Authorize(Policy = "DoctorsOnly")]
public async Task OrderMedication(Guid patientId, string drugName)
{
// Only doctors can call this method
}
[Authorize(Policy = "ClinicalStaff")]
public async Task SubscribeToWard(string wardId)
{
// Any clinical staff can subscribe to ward updates
}
}
// Or with Minimal API hub registration (Policy on MapHub)
app.MapHub<ClinicalDashboardHub>("/hubs/clinical")
.RequireAuthorization("ClinicalStaff");JavaScript Client — Sending the Token
// @microsoft/signalr npm package
const connection = new signalR.HubConnectionBuilder()
.withUrl("/hubs/clinical", {
// Token retrieved from your auth state (memory, cookie, etc.)
accessTokenFactory: () => getAccessToken(),
})
.withAutomaticReconnect([0, 2000, 10000, 30000]) // retry intervals ms
.build();
await connection.start();Token Refresh for Long-Lived Connections
SignalR connections can last for hours. JWTs expire in 15 minutes. Handle token refresh:
let accessToken = await getAccessToken();
const connection = new signalR.HubConnectionBuilder()
.withUrl("/hubs/clinical", {
accessTokenFactory: async () => {
// Called on reconnect or when a new token is needed
if (isTokenExpiringSoon(accessToken))
accessToken = await refreshToken();
return accessToken;
},
})
.withAutomaticReconnect()
.build();
connection.onreconnecting(error => {
console.log("Reconnecting...", error);
updateConnectionStatus("reconnecting");
});
connection.onreconnected(connectionId => {
console.log("Reconnected:", connectionId);
updateConnectionStatus("connected");
resubscribeToGroups(); // re-join groups after reconnect
});Accessing the Authenticated User in Hub Methods
public sealed class ClinicalDashboardHub : Hub<IClinicalDashboardClient>
{
[Authorize]
public async Task SubscribeToWard(string wardId)
{
// Context.User is the authenticated ClaimsPrincipal
var userId = Context.User!.FindFirstValue(JwtRegisteredClaimNames.Sub);
var role = Context.User!.FindFirstValue(ClaimTypes.Role);
var department = Context.User!.FindFirstValue("department");
// Validate the user has access to this specific ward
if (department != wardId && !Context.User!.IsInRole("Admin"))
throw new HubException("Access to this ward requires matching department assignment.");
await Groups.AddToGroupAsync(Context.ConnectionId, $"ward:{wardId}");
}
}Hub Authentication vs HTTP Authentication
Same JWT, different delivery:
HTTP API: Authorization: Bearer {token}
SignalR Hub: ?access_token={token} in query string
Same validation:
Both use the same AddJwtBearer configuration
Same ClaimsPrincipal populated in Context.User
Separate route protection:
app.MapControllers() → protected by [Authorize] on controllers
app.MapHub(path) → protected by [Authorize] on Hub class or RequireAuthorization()
Both use the same DI auth pipeline Logging Connection Attempts
public override async Task OnConnectedAsync()
{
var userId = Context.User?.FindFirstValue(JwtRegisteredClaimNames.Sub) ?? "anonymous";
var role = Context.User?.FindFirstValue(ClaimTypes.Role) ?? "none";
_logger.LogInformation(
"SignalR connected: {UserId} (Role: {Role}) ConnectionId: {ConnectionId}",
userId, role, Context.ConnectionId);
if (Context.User?.Identity?.IsAuthenticated != true)
{
_logger.LogWarning(
"Unauthenticated SignalR connection: {ConnectionId}",
Context.ConnectionId);
Context.Abort(); // reject unauthenticated connections immediately
return;
}
await base.OnConnectedAsync();
}Production issue I've seen: A team forgot the
OnMessageReceivedevent for extracting the JWT from the query string. All SignalR connections authenticated as anonymous. The hub had[Authorize]but the token was never read — every connection was rejected with 401. Adding theOnMessageReceivedhook is required for SignalR WebSocket authentication to work.
Red Flag / Green Answer
Red Flag: "We store the JWT in sessionStorage and pass it in the SignalR connection URL — wss://api.example.com/hubs/clinical?access_token=eyJ..."
The token is in the URL — URLs appear in server logs, browser history, and proxy logs. The JWT is exposed to anyone with access to those logs. Use
accessTokenFactoryfunction (not a URL parameter you construct) — SignalR's client library sends it as a query parameter automatically.
Green Answer:
Store the access token in memory (JavaScript variable or React state). Use
accessTokenFactory: () => getTokenFromMemory()inHubConnectionBuilder. The SignalR library handles attaching it to the upgrade request — you never construct the URL with the token embedded.
Key Takeaway
SignalR JWT authentication requires the
OnMessageReceivedevent to read the token from the query string — WebSocket upgrades cannot use custom headers. Apply[Authorize]to hub classes orRequireAuthorization()onMapHub. Access the authenticated user viaContext.User. Handle token refresh in theaccessTokenFactoryfunction for long-lived connections. Re-subscribe to groups after automatic reconnect — groups are per-connection and are reset on reconnect.
Found this helpful?
Leave a comment
Have a question, correction, or just found this helpful? Leave a note below.