Skip to main content
SEMastery
ASP.NETintermediate

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.

12 min readUpdated September 15, 2025

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.

How a JWT proves who you are on a normal API call

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.

Where the token travels for each transport

The table below sums up the difference so it stays clear in your head.

Client typeCan set headers?Where the token goes
Browser (WebSocket / SSE)NoQuery string access_token
Browser (Long Polling)YesAuthorization header
.NET clientYesAuthorization header
Java clientYesAuthorization 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

Login
Get Token
Connect
Authorized

Steps

1

Login

User sends credentials to the auth endpoint

2

Get Token

Server returns a signed JWT

3

Connect

Client opens the hub with the token attached

4

Authorized

Hub checks the token and lets the user in

The four big steps a user goes through

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.JwtBearer

That 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.

What OnMessageReceived does on each request

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 classEvery method in the hub
On a single methodJust 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

Build
Provide token
Start
Chat

Steps

1

Build

Create the HubConnection with withUrl

2

Provide token

Pass accessTokenFactory returning the JWT

3

Start

Call start() and SignalR attaches the token

4

Chat

Invoke hub methods as a known user

The browser hands SignalR a function, and SignalR does the rest

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_token from logs.
  • Turn on CloseOnAuthenticationExpiration if you want connections to drop the moment a token expires.
Where a token could leak and how to block it

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 OnMessageReceived event. 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 UseAuthentication comes after MapHub, the user is never identified. Put authentication and authorization before mapping the hub.
  • Path mismatch. The path in StartsWithSegments must match the path in MapHub. If you map /hubs/chat but 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.

TermQuestion it answersTool we used
AuthenticationWho are you?JWT bearer token
AuthorizationWhat 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 OnMessageReceived event reads that value, but only for hub paths, to keep it safe.
  • UseAuthentication must come before UseAuthorization, and both before MapHub.
  • 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) or AccessTokenProvider (.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

Related Posts