🚀 New: The .NET Senior PlaybookSave 20% with launch discount 

newsletter

How to Add JWT Authentication to SignalR Hubs in ASP.NET Core

Download source code
7 min read

Newsletter Sponsors

Ship before the idea dies: get instant VMs with exe.dev.

Every AI model fails at simple multi-step reasoning.
This new Oracle blog post walks through 16 research-backed reasoning strategies you can apply to any Ollama model by adding a single tag to the model name: gemma3:270m+cot instead of gemma3:270m. No code changes. No new tooling. Just a drop-in client that routes to the right cognitive agent.

You will learn when to use Chain of Thought (88.7% accuracy on math benchmarks), when branching methods like Tree of Thoughts are worth the latency, how ReAct grounds reasoning in external tools, and how the auto-router picks a strategy for you.

If you're hitting a reasoning wall with small models, this is the fastest way to understand what actually works.

👉 Read the full post — free

SignalR makes real-time communication in .NET applications simple. You can send live data, notifications, and streaming updates to clients with minimal code.

By default, SignalR hubs are accessible to all clients. Clients can connect, call hub methods, and receive messages without authentication.

In production, you may need to know who is connecting to your hub, what they are allowed to do, and how to reject unauthorized access.

JWT authentication is the standard for securing SignalR hubs in any environment, including browsers. It works with WebSockets, Server-Sent Events, and Long Polling transports.

In this post, we will explore:

  • Why SignalR Needs Authentication
  • Setting Up JWT Authentication for SignalR
  • How to Stream Events with SignalR for a Given User
  • Role-Based Authorization on Hub Methods
  • Connecting from a JavaScript Client
  • Connecting from a .NET Client
  • Security Best Practices for SignalR

Let's dive in.

Copied

Why SignalR Needs Authentication

SignalR uses multiple transport protocols to maintain real-time connections: WebSockets, Server-Sent Events, and Long Polling.

By default, any client can connect to a SignalR hub and call its methods.

This creates several problems in production:

  • No user identity - You can't tell who is connected or send messages to specific users.
  • No access control - Anyone can call any hub method, including admin operations.
  • No audit trail - You can't log which users performed which actions.
  • Security exposure - Sensitive data could be streamed to unauthorized clients.

Why JWT and not Cookies?

Cookies work well for browser-based apps where users log in through a web form.

But JWT tokens are the better choice when:

  • Your clients are mobile apps, desktop apps, or SPAs.
  • You need cross-platform authentication.
  • Your API and frontend run on different domains.
  • You're building microservices that need stateless authentication.

There is one important detail about how tokens are handled in SignalR connections. In standard REST APIs, the client sends the JWT token in the Authorization HTTP header. But SignalR can't always do this.

When using WebSockets or Server-Sent Events in a browser, the browser API does not allow setting custom headers. Instead, the token is sent as a query string parameter: ?access_token=<token>.

This means you need extra configuration on the server to read the token from the query string. We will cover this setup in the next section.

For a deeper dive into authentication and authorization in ASP.NET Core, read my article on Authentication and Authorization Best Practices.

Copied

Setting Up JWT Authentication for SignalR

Let's build the backend for the stock price streaming application.

In Modern .NET versions, you no longer need to install the SignalR NuGet package. SignalR is now included in the ASP.NET Core Web SDK.

Here is how you can configure SignalR in your project:

csharp
builder.Services.AddSignalR(); var app = builder.Build(); app.UseAuthentication(); app.UseAuthorization(); // Map the SignalR hub app.MapHub<StockPriceHub>("/hubs/stocks");

We need to configure JWT bearer authentication and add the OnMessageReceived event handler that extracts the token from the query string for SignalR connections:

csharp
builder.Services.AddAuthentication(options => { options.DefaultAuthenticateScheme = JwtBearerDefaults.AuthenticationScheme; options.DefaultChallengeScheme = JwtBearerDefaults.AuthenticationScheme; }) .AddJwtBearer(options => { var key = new SymmetricSecurityKey(Encoding.UTF8.GetBytes(authConfig.Key)); options.TokenValidationParameters = new TokenValidationParameters { ValidateIssuer = true, ValidateAudience = true, ValidateLifetime = true, ValidateIssuerSigningKey = true, ValidIssuer = authConfig.Issuer, ValidAudience = authConfig.Audience, IssuerSigningKey = key }; 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; } }; });

When a browser connects to a SignalR hub via WebSockets or SSE, it can't send the Authorization header. The JavaScript client sends the token as ?access_token=<token>.

The OnMessageReceived event fires before the JWT middleware validates the token.

Token extraction is limited to paths starting with /hubs. This avoids reading tokens from query strings on other endpoints.

Copied

How to Stream Events with SignalR for a Given User

Now let's build the SignalR hub that streams stock prices to authenticated users. Users can subscribe to specific stock symbols and receive updates only for the stocks they care about.

Let's go step by step.

Step 1: Create the SignalR Hub

Start by creating a hub class that requires authentication. The [Authorize] attribute requires every connection to present a valid JWT.

Without a valid token, the client receives a 401 Unauthorized response and the connection is rejected.

csharp
[Authorize] public class StockPriceHub( StockService stockService, ILogger<StockPriceHub> logger) : Hub { // Track subscriptions per connection private static readonly ConcurrentDictionary<string, HashSet<string>> Subscriptions = new(); public override Task OnConnectedAsync() { var userId = Context.User?.FindFirst(ClaimTypes.Email)?.Value ?? "unknown"; logger.LogInformation("User '{User}' connected with ConnectionId: {ConnectionId}", userId, Context.ConnectionId); Subscriptions[Context.ConnectionId] = new HashSet<string>(StringComparer.OrdinalIgnoreCase); return base.OnConnectedAsync(); } public override Task OnDisconnectedAsync(Exception? exception) { var userId = Context.User?.FindFirst(ClaimTypes.Email)?.Value ?? "unknown"; logger.LogInformation("User '{User}' disconnected. ConnectionId: {ConnectionId}", userId, Context.ConnectionId); Subscriptions.Remove(Context.ConnectionId); return base.OnDisconnectedAsync(exception); } }

The static Subscriptions dictionary tracks which stock symbols each connected client is subscribed to, keyed by ConnectionId.

Here we override OnConnectedAsync and OnDisconnectedAsync to track when users connect and disconnect. When a user connects, we create an empty subscription set for their connection. When they disconnect, we clean up their subscriptions.

Inside any hub method, you can access the authenticated user through Context.User. This gives you access to all JWT claims, including email, roles, and custom claims.

Just like in ASP .NET Core Controllers and Minimal APIs

Step 2: Subscribe and Unsubscribe to Stock Symbols

Clients call these methods to choose which stock symbols they want to receive. The subscription state is stored in the static dictionary keyed by ConnectionId.

csharp
public async Task Subscribe(string symbol) { var normalizedSymbol = symbol.ToUpperInvariant(); var availableSymbols = StockService.GetAvailableSymbols(); if (!availableSymbols.Contains(normalizedSymbol)) { await Clients.Caller.SendAsync("Error", $"Symbol '{symbol}' is not available. Available symbols: {string.Join(", ", availableSymbols)}"); return; } if (Subscriptions.TryGetValue(Context.ConnectionId, out var symbols)) { symbols.Add(normalizedSymbol); } var userId = Context.User?.FindFirst(ClaimTypes.Email)?.Value; logger.LogInformation("User '{User}' subscribed to {Symbol}", userId, normalizedSymbol); await Clients.Caller.SendAsync("Subscribed", normalizedSymbol); } public async Task Unsubscribe(string symbol) { var normalizedSymbol = symbol.ToUpperInvariant(); if (Subscriptions.TryGetValue(Context.ConnectionId, out var symbols)) { symbols.Remove(normalizedSymbol); } var userId = Context.User?.FindFirst(ClaimTypes.Email)?.Value; logger.LogInformation("User '{User}' unsubscribed from {Symbol}", userId, normalizedSymbol); await Clients.Caller.SendAsync("Unsubscribed", normalizedSymbol); }

We validate the symbol against available stocks and notify the caller with Clients.Caller.SendAsync. If the symbol is invalid, we send an error message back to the caller.

Step 3: Stream Stock Prices

SignalR supports server-to-client streaming. You can call the connected clients in the SignalR hub to send stock prices as they are generated.

And you can use the IAsyncEnumerable<T> implementation, which enables server-to-client streaming.

The client starts the stream and receives stock prices as they are generated.

csharp
public async IAsyncEnumerable<StockPriceEvent> StreamStockPrices( [EnumeratorCancellation] CancellationToken cancellationToken) { HashSet<string> subscribedSymbols = Subscriptions.TryGetValue(Context.ConnectionId, out var symbols) ? new HashSet<string>(symbols, StringComparer.OrdinalIgnoreCase) : new HashSet<string>(StringComparer.OrdinalIgnoreCase); var userId = Context.User?.FindFirst(ClaimTypes.Email)?.Value; await foreach (var stockPrice in stockService.StreamPrices(subscribedSymbols, cancellationToken)) { yield return stockPrice; } }

The method reads the current subscription state and passes it to the StockService. If the user hasn't subscribed to any specific symbols, they receive all stock prices.

The StockService generates simulated stock prices. It's registered as a Singleton because all connected clients share the same price feed.

Copied

Role-Based Authorization on Hub Methods

The [Authorize] attribute on the hub class ensures all users must be authenticated. But you can also apply different authorization policies to individual hub methods.

This is useful when certain actions should be restricted to administrators, such as viewing all connected users or resetting subscriptions.

Let's add admin-only methods to our hub:

csharp
[Authorize("Admin")] public async Task BroadcastMessage(string message) { var adminEmail = Context.User?.FindFirst(ClaimTypes.Email)?.Value; logger.LogInformation("Admin '{Admin}' broadcasting message: {Message}", adminEmail, message); await Clients.All.SendAsync("SystemMessage", message); } [Authorize("Admin")] public int GetConnectedUsersCount() { return Subscriptions.Count; }

The BroadcastMessage method sends a system message to all connected clients. The GetConnectedUsersCount method returns the number of currently connected users.

Both methods use [Authorize("Admin")], which requires the user to have the Admin role in their JWT token.

We configured this policy in Program.cs:

csharp
builder.Services.AddAuthorization(options => { options.AddPolicy("Admin", policy => policy.RequireRole("Admin")); });

You can also use claims-based authorization in your hub methods. It works the same as in Controllers and Minimal APIs.

What happens when a non-admin calls these methods?

If a user without the Admin role tries to call BroadcastMessage or GetConnectedUsersCount, SignalR returns an error to the caller. The connection stays open, but the specific method call is rejected.

You can check roles and claims directly in your hub method code:

csharp
public async Task SomeMethod() { var email = Context.User?.FindFirst(ClaimTypes.Email)?.Value; var isAdmin = Context.User?.IsInRole("Admin") ?? false; var userId = Context.User?.FindFirst(ClaimTypes.NameIdentifier)?.Value; if (isAdmin) { // Admin-specific logic } }

This approach gives you more flexibility when authorization logic is more complex than what role-based policies can handle.

Copied

Connecting from a JavaScript Client

Let's connect to our secured SignalR hub from a JavaScript client.

First, you need the SignalR JavaScript client library. You can install it via npm or use a CDN:

bash
npm install @microsoft/signalr

Step 1: Get a JWT Token

Before connecting to the hub, the client needs to authenticate and get a JWT token from the login endpoint:

javascript
const API_URL = 'http://localhost:5000'; let token = null; async function login(email, password) { const response = await fetch(`${API_URL}/api/login`, { method: 'POST', headers: { 'Content-Type': 'application/json' }, body: JSON.stringify({ email, password }) }); if (response.ok) { const data = await response.json(); token = data.token; } }

The token is stored in a variable and will be passed to SignalR in the next step.

Step 2: Connect to the Hub with accessTokenFactory

The accessTokenFactory option is a function that returns the JWT token. SignalR calls it before every HTTP request, including the initial WebSocket handshake.

javascript
let connection = new signalR.HubConnectionBuilder() .withUrl(`${API_URL}/hubs/stocks`, { accessTokenFactory: () => token }) .withAutomaticReconnect() .configureLogging(signalR.LogLevel.Information) .build(); await connection.start();

The withAutomaticReconnect() method tells SignalR to automatically reconnect when the connection drops. On each reconnect, SignalR calls the accessTokenFactory again to get a fresh token.

If the token has expired, you can refresh it inside the factory function:

javascript
accessTokenFactory: async () => { if (isTokenExpired(token)) { token = await refreshToken(); } return token; }

Step 3: Listen for Hub Events

Register handlers for messages the hub sends to the client. These correspond to the Clients.Caller.SendAsync(...) calls in our hub:

javascript
connection.on('Subscribed', (symbol) => { console.log('Subscribed to:', symbol); }); connection.on('Unsubscribed', (symbol) => { console.log('Unsubscribed from:', symbol); }); connection.on('SystemMessage', (message) => { console.log('System:', message); }); connection.on('Error', (message) => { console.error('Hub error:', message); }); connection.onclose(() => { console.log('Disconnected from hub'); });

Step 4: Call Hub Methods

Use invoke to call methods on the hub. These are the same methods we defined in StockPriceHub:

javascript
// Get available stock symbols const symbols = await connection.invoke('GetAvailableSymbols'); console.log('Available symbols:', symbols); // Subscribe to specific stocks await connection.invoke('Subscribe', 'MSFT'); await connection.invoke('Subscribe', 'AAPL'); // Unsubscribe from a stock await connection.invoke('Unsubscribe', 'AAPL');

Step 5: Start a Stream

Call the hub's streaming method using connection.stream(). Each time the server yields a new StockPriceEvent, the next callback fires:

javascript
const subscription = connection.stream('StreamStockPrices') .subscribe({ next: (event) => { console.log(`${event.symbol}: $${event.price} at ${event.timestamp}`); }, error: (err) => { console.error('Stream error:', err); }, complete: () => { console.log('Stream completed'); } }); // To stop streaming later: subscription.dispose();

The stream stays open until the client calls dispose() or the connection drops.

Copied

Connecting from a .NET Client

If you need to connect to the hub from a .NET application, such as a console app, a background service, or another microservice, you can install the following package:

bash
dotnet add package Microsoft.AspNetCore.SignalR.Client

Here is a brief example:

csharp
using Microsoft.AspNetCore.SignalR.Client; var token = "your-jwt-token-here"; // Obtained from the login endpoint var connection = new HubConnectionBuilder() .WithUrl("https://localhost:5001/hubs/stocks", options => { options.AccessTokenProvider = () => Task.FromResult(token)!; }) .WithAutomaticReconnect() .Build(); // Listen for system messages connection.On<string>("SystemMessage", message => { Console.WriteLine($"System: {message}"); }); // Connect await connection.StartAsync(); Console.WriteLine("Connected to hub"); // Subscribe to a stock await connection.InvokeAsync("Subscribe", "MSFT"); // Start streaming var stream = connection.StreamAsync<StockPriceEvent>("StreamStockPrices"); await foreach (var price in stream) { Console.WriteLine($"{price.Symbol}: ${price.Price} at {price.Timestamp:HH:mm:ss}"); } record StockPriceEvent(string Id, string Symbol, decimal Price, DateTime Timestamp);

The .NET client can send the token in the HTTP Authorization header, so it does not need the query string workaround.

Copied

Security Best Practices for SignalR

When deploying SignalR with JWT authentication to production, there are several security considerations you should address.

1. Access Token Logging

When using WebSockets or Server-Sent Events, the browser sends the access token as a query string parameter.

Many web servers log the full URL for each request, including query string values. ASP.NET Core logs request URLs at the Information level by default.

This means your JWT tokens could end up in your log files, which is a security risk.

To prevent this, configure the Microsoft.AspNetCore.Hosting logger to Warning level or above:

json
{ "Logging": { "LogLevel": { "Default": "Information", "Microsoft.AspNetCore.Hosting": "Warning", "Microsoft.AspNetCore.Routing": "Warning" } } }

This suppresses the request-level logging that includes query strings.

2. Always Use HTTPS

Since the access token is transmitted in the query string, you must use HTTPS to encrypt the connection. Without HTTPS, anyone on the network can see the token in plain text.

In production, always enforce HTTPS:

csharp
app.UseHttpsRedirection();

3. Configure CORS Properly

SignalR requires specific CORS settings. Allow only your known frontend origins:

csharp
builder.Services.AddCors(options => { options.AddPolicy("AllowFrontend", policy => { policy .WithOrigins("https://yourfrontend.com") .AllowAnyHeader() .AllowAnyMethod() .AllowCredentials(); }); });

Never use AllowAnyOrigin() in production. CORS protections do not apply to WebSocket connections, so always validate origins for your specific use case.

4. Token Expiration and Connection Lifetime

JWT validation happens only when the connection is established. Once a WebSocket connection is open, the server does not re-validate the token.

This means a user with an expired token can remain connected until the connection drops.

To handle this, you can configure CloseOnAuthenticationExpiration in your hub options:

csharp
app.MapHub<StockPriceHub>("/hubs/stocks", options => { options.CloseOnAuthenticationExpiration = true; });

This setting closes the connection when the authentication token expires.

On the client side, use accessTokenFactory to return a fresh token on reconnect:

javascript
accessTokenFactory: async () => { if (isTokenExpired(token)) { token = await refreshToken(); } return token; }

5. Don't Expose ConnectionId

The ConnectionId is an internal identifier for each connection. Exposing it to clients or other systems can allow impersonation attacks on older versions of SignalR.

In ASP.NET Core 3.0 and later, a separate ConnectionToken is used internally, making ConnectionId less sensitive. However, it's still best practice to avoid exposing it in your hub methods or API responses.

6. Limit Message Sizes

SignalR uses per-connection buffers. By default, the maximum message size is 32 KB. You can adjust this based on your needs:

csharp
builder.Services.AddSignalR(options => { options.MaximumReceiveMessageSize = 64 * 1024; // 64 KB });

Keep message sizes small to prevent memory exhaustion from malicious clients.

Hope you find this newsletter useful. See you next time.

You can download source code for this newsletter for free
Download source code

Whenever you're ready, here's how I can help you:

The .NET Senior Playbook — 800+ real-world interview questions with expert answers across 50 chapters. You try to answer each question first, then reveal the full solution — and a test after every chapter proves it actually stuck. Finish, and you earn a verifiable certificate for your LinkedIn.

The .NET Senior Playbook
View the Playbook

Enjoyed this article? Share it with your network

Improve Your .NET and Architecture Skills

Join my community of 25,000+ developers and architects.

Each week you will get 1 practical tip with best practices and real-world examples.

Learn how to craft better software with source code available for my newsletter.

Join 25,000+ developers already reading
No spam. Unsubscribe any time.