Learnixo
Back to blog
AI Systemsintermediate

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.

Asma Hafeez KhanMay 16, 20265 min read
SignalRAuthenticationJWTASP.NET Core.NET
Share:𝕏

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

C#
// 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

C#
// 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

JAVASCRIPT
// @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:

JAVASCRIPT
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

C#
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

C#
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 OnMessageReceived event 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 the OnMessageReceived hook 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 accessTokenFactory function (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() in HubConnectionBuilder. 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 OnMessageReceived event to read the token from the query string — WebSocket upgrades cannot use custom headers. Apply [Authorize] to hub classes or RequireAuthorization() on MapHub. Access the authenticated user via Context.User. Handle token refresh in the accessTokenFactory function for long-lived connections. Re-subscribe to groups after automatic reconnect — groups are per-connection and are reset on reconnect.

Enjoyed this article?

Explore the AI Systems learning path for more.

Found this helpful?

Share:𝕏

Leave a comment

Have a question, correction, or just found this helpful? Leave a note below.