How to Add JWT Authentication to SignalR Hubs in ASP.NET Core
A beginner-friendly guide to securing SignalR hubs with JWT tokens in ASP.NET Core, including the access_token query string trick and the [Authorize] attribute.
Imagine a wedding hall with a guard at the gate. Everyone wants to come in, dance, and eat. But the guard does not let just anyone walk in. You must show your invitation card. The card has your name on it and a stamp that proves it is real. The guard looks at the card, checks the stamp, and only then waves you inside.
A JWT (JSON Web Token) is exactly like that invitation card. It is a small signed note that says "this is who I am" and carries a stamp the server can trust. SignalR is the dance floor where everyone talks to each other in real time. In this guide we will hire a guard for the SignalR dance floor, so only people with a valid card can join.
We will keep the words simple. By the end you will know what a JWT is, why SignalR needs a special trick to read it, and how to wire it all up in ASP.NET Core.
A quick refresher on the two pieces
Before we mix them, let us remember what each piece does on its own.
SignalR keeps an open line between the server and the browser. Instead of the browser asking again and again, the server can push a message the moment something happens. Chat, live scores, and notifications all use it.
JWT bearer authentication is a way to prove who you are using a signed token. You log in once, the server hands you a token, and you send that token with every request. The server checks the stamp (the signature) to know the token is genuine and not faked.
Here is how a normal web API uses a JWT.
On a normal API, the token rides inside an HTTP header called Authorization. Simple. But SignalR has a small problem with that, and the next section explains it.
Why SignalR needs a special trick
SignalR tries to use WebSockets first, because they are the fastest way to keep a line open. The trouble is this: when a browser opens a WebSocket, it cannot add custom headers. The browser API simply does not allow it. The same is true for Server-Sent Events.
So the usual plan of "put the token in the Authorization header" does not work in the browser. SignalR solves this by putting the token in the URL instead, as a query string value named access_token. The connection ends up looking like this:
wss://yoursite.com/hubs/chat?access_token=eyJhbGciOi...
Our job on the server is to teach the JWT middleware to also look in that query string, not only in the header. That one small piece of code is the heart of this whole article.
The table below sums up the difference so it stays clear in your head.
| Client type | Can set headers? | Where the token goes |
|---|---|---|
| Browser (WebSocket / SSE) | No | Query string access_token |
| Browser (Long Polling) | Yes | Authorization header |
| .NET client | Yes | Authorization header |
| Java client | Yes | Authorization header |
Because the browser is the most common case and it cannot use headers, you must handle the query string. If you skip it, your hub will reject browser users with a 401 even when their token is perfectly valid.
The full flow, start to finish
Let us picture the whole journey, from logging in to joining the protected hub. This is the shape of everything we are about to build.
From login to a secure hub connection
Steps
Login
User sends credentials to the auth endpoint
Get Token
Server returns a signed JWT
Connect
Client opens the hub with the token attached
Authorized
Hub checks the token and lets the user in
Keep this picture in mind. Every code block below fits into one of these four steps.
Step 1: Install the package
If you are starting fresh, create a web API project and add the JWT bearer package. SignalR itself is already part of ASP.NET Core, so you only need the auth package.
dotnet new web -n SecureSignalR
cd SecureSignalR
dotnet add package Microsoft.AspNetCore.Authentication.JwtBearerThat is the only extra package. Everything else ships with the framework on .NET 10.
Step 2: Configure JWT on the server
Now the important part. We tell ASP.NET Core to use JWT bearer authentication, and we add the special event that reads the token from the query string for hub requests.
using Microsoft.AspNetCore.Authentication.JwtBearer;
using Microsoft.IdentityModel.Tokens;
using System.Text;
var builder = WebApplication.CreateBuilder(args);
var key = Encoding.UTF8.GetBytes(builder.Configuration["Jwt:Key"]!);
builder.Services.AddAuthentication(options =>
{
options.DefaultAuthenticateScheme = JwtBearerDefaults.AuthenticationScheme;
options.DefaultChallengeScheme = 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(key)
};
// The magic part: read the token from the query string
// ONLY when the request is heading to our hub.
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/chat"))
{
context.Token = accessToken;
}
return Task.CompletedTask;
}
};
});
builder.Services.AddAuthorization();
builder.Services.AddSignalR();
var app = builder.Build();
app.UseAuthentication();
app.UseAuthorization();
app.MapHub<ChatHub>("/hubs/chat");
app.Run();Two things deserve attention.
First, the order in app matters. UseAuthentication must come before UseAuthorization, and both come before MapHub. The guard must stand at the gate before deciding who gets in.
Second, look at the path check: path.StartsWithSegments("/hubs/chat"). We only pull the token from the query string for our hub. We do not do it for every URL. This keeps tokens out of the query string on normal API routes, which is safer.
Step 3: Protect the hub
Reading the token is half the job. We still have to say "this hub requires a logged-in user." We do that with the [Authorize] attribute, the same one you use on controllers.
using Microsoft.AspNetCore.Authorization;
using Microsoft.AspNetCore.SignalR;
[Authorize]
public class ChatHub : Hub
{
public async Task SendMessage(string message)
{
// Context.User is filled in from the JWT claims.
var userName = Context.User?.Identity?.Name ?? "Unknown";
await Clients.All.SendAsync("ReceiveMessage", userName, message);
}
// Only admins may kick someone. Everyone else gets an error.
[Authorize(Roles = "Admin")]
public async Task KickUser(string userName)
{
await Clients.All.SendAsync("UserKicked", userName);
}
}Notice two levels of protection. The [Authorize] on the class means you must be signed in to use the hub at all. The [Authorize(Roles = "Admin")] on KickUser adds a second lock: only users whose token carries the Admin role can call that one method. Everyone else can still chat, but the kick button does nothing for them.
Inside the hub, Context.User holds all the claims from the token. You can read the name, the user id, roles, email, anything the token carried. SignalR fills it in for you once the token is validated.
Where you put [Authorize] | What it protects |
|---|---|
| On the hub class | Every method in the hub |
| On a single method | Just that one method |
With a role, like Roles = "Admin" | Only users who have that role |
With a policy, like "CanModerate" | Only users who pass your custom rule |
Step 4: Connect from the client
Now the browser side. When you build the connection, you give SignalR a small function called accessTokenFactory. SignalR calls it to get the token and attaches it for you, choosing the header or the query string automatically based on the transport.
const connection = new signalR.HubConnectionBuilder()
.withUrl("/hubs/chat", {
accessTokenFactory: () => localStorage.getItem("jwt")
})
.build();
connection.on("ReceiveMessage", (user, message) => {
console.log(`${user}: ${message}`);
});
await connection.start();
await connection.invoke("SendMessage", "Hello everyone!");If you use the .NET client instead of a browser, the idea is the same. You set AccessTokenProvider, and the .NET client is free to use the proper header.
var connection = new HubConnectionBuilder()
.WithUrl("https://yoursite.com/hubs/chat", options =>
{
options.AccessTokenProvider = () => Task.FromResult(myJwtToken)!;
})
.Build();
await connection.StartAsync();One important note from the docs: the access token function is called before every request SignalR makes, not just the first one. If your token is about to expire, this is the perfect place to fetch a fresh one and return it, so the connection keeps working without a drop.
What the client does when connecting
Steps
Build
Create the HubConnection with withUrl
Provide token
Pass accessTokenFactory returning the JWT
Start
Call start() and SignalR attaches the token
Chat
Invoke hub methods as a known user
A real concern: tokens in logs
There is one safety point you must not ignore. Because the token rides in the URL for browsers, it can land in places that log full URLs. Web servers, proxies, and reverse proxies often write the whole request line, query string included, into their log files. That means a copy of someone's token could sit in a plain text log.
When you use HTTPS, the token is encrypted while it travels over the wire, so an outsider sniffing the network cannot read it. The risk is on your own servers, in your own logs. Here is how to stay safe:
- Always use HTTPS. Never run a real app over plain HTTP.
- Keep token lifetimes short, like a few minutes, so a leaked token is useless quickly.
- Configure your servers and proxies to not log query strings, or to scrub
access_tokenfrom logs. - Turn on
CloseOnAuthenticationExpirationif you want connections to drop the moment a token expires.
Common mistakes and how to fix them
When things do not work, it is almost always one of these. Check them in order.
- You forgot the
OnMessageReceivedevent. The header works in Postman and the .NET client, but the browser gets a 401. This is the number one cause. Add the query string reader. - Wrong order in Program.cs. If
UseAuthenticationcomes afterMapHub, the user is never identified. Put authentication and authorization before mapping the hub. - Path mismatch. The path in
StartsWithSegmentsmust match the path inMapHub. If you map/hubs/chatbut check for/chat, the token is never read. - Forgot
[Authorize]on the hub. Without it, anyone can connect, even with no token at all. The hub stays wide open. - Token signing key mismatch. If the key that signs the token is not the same key the server validates with, every token looks fake. Keep the key in one trusted place.
Putting authentication and authorization together
It helps to remember the difference between the two words that sound alike.
Authentication answers "who are you?" That is the JWT proving identity. Authorization answers "are you allowed to do this?" That is the [Authorize] attribute and roles deciding what you can touch. You need both. A guard who only checks names but lets everyone do anything is not much of a guard.
| Term | Question it answers | Tool we used |
|---|---|---|
| Authentication | Who are you? | JWT bearer token |
| Authorization | What can you do? | [Authorize], roles, policies |
With both in place, your hub knows each user by name and controls exactly which methods each one may call.
Quick recap
- A JWT is like a signed invitation card that proves who a user is.
- SignalR uses WebSockets, and browsers cannot put a token in a header for WebSockets.
- So SignalR sends the token in the URL as a query string value named
access_token. - On the server, the
OnMessageReceivedevent reads that value, but only for hub paths, to keep it safe. UseAuthenticationmust come beforeUseAuthorization, and both beforeMapHub.- Add
[Authorize]to the hub class to require login, and to single methods or roles for finer control. - On the client, pass an
accessTokenFactory(browser) orAccessTokenProvider(.NET) and SignalR attaches the token for you. - Tokens in the URL can leak into server logs, so use HTTPS, short token lifetimes, and scrub your logs.
References and further reading
- Authentication and authorization in ASP.NET Core SignalR (Microsoft Learn)
- Security considerations in ASP.NET Core SignalR (Microsoft Learn)
- ASP.NET Core SignalR configuration (Microsoft Learn)
- Overview of ASP.NET Core SignalR (Microsoft Learn)
Related Posts
Adding Real-Time Functionality to .NET Apps with SignalR
Learn ASP.NET Core SignalR step by step: hubs, clients, groups, and scaling with Redis or Azure, explained for absolute beginners.
Authentication and Authorization Best Practices in ASP.NET Core
A friendly guide to authentication and authorization in ASP.NET Core for .NET 10 — JWT, cookies, claims, roles, policies, and security best practices with diagrams.
Refresh Tokens and Token Revocation in ASP.NET Core: A Beginner Guide
Learn refresh tokens and token revocation in ASP.NET Core with simple words, diagrams, and code. Short access tokens, rotation, reuse detection, and safe logout.
How to Customize ASP.NET Core Identity With EF Core for Your Project Needs
Learn to customize ASP.NET Core Identity with EF Core: add user fields, change the key type, extend roles, and run migrations safely on .NET 10.
Integrate Keycloak with ASP.NET Core Using OAuth 2.0
A beginner-friendly guide to securing an ASP.NET Core API and web app with Keycloak using OAuth 2.0 and OpenID Connect, with diagrams, tables, and copy-paste code.
Scheduling Background Jobs with Quartz.NET in ASP.NET Core
Learn Quartz.NET step by step in ASP.NET Core: jobs, triggers, cron schedules, dependency injection, and database persistence, explained for beginners.