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

How to Add JWT Authentication to SignalR Hubs in ASP.NET Core
Newsletter Sponsors
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.
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.
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.
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:
csharpbuilder.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:
csharpbuilder.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.
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.
csharppublic 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.
csharppublic 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.
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:
csharpbuilder.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:
csharppublic 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.
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:
bashnpm 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:
javascriptconst 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.
javascriptlet 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:
javascriptaccessTokenFactory: 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:
javascriptconnection.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:
javascriptconst 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.
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:
bashdotnet add package Microsoft.AspNetCore.SignalR.Client
Here is a brief example:
csharpusing 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.
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:
csharpapp.UseHttpsRedirection();
3. Configure CORS Properly
SignalR requires specific CORS settings. Allow only your known frontend origins:
csharpbuilder.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:
csharpapp.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:
javascriptaccessTokenFactory: 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:
csharpbuilder.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.
Download source codeYou can download source code for this newsletter for free
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.

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.