If you've shipped a SignalR hub behind JWT bearer authentication, you've almost certainly hit this: a dispatcher opens a live tracking dashboard first thing in the morning, the access token issued at login expires after fifteen minutes, and the connection just... keeps working. Nothing revalidates it. Roles change, the token expires, a user gets deactivated — the hub doesn't care, because SignalR caches the ClaimsPrincipal once at connection time and never looks at it again unless you tell it to.

The workaround most teams reach for is CloseOnAuthenticationExpiration, which forces the connection closed the moment the token's exp claim passes. That's correct from a security standpoint, but it's brutal for anything long-lived: a live order board, a chat client, a monitoring dashboard. Every token lifetime becomes a forced reconnect, group memberships have to be rebuilt, and any message sent during the reconnect window is simply lost.

.NET 11 finally closes this gap with authentication refresh — a way for a client to hand the hub a new token over the existing connection, without dropping it. It's a genuinely useful feature that's been requested since SignalR's early days, and it ships with enough configuration knobs to actually trust it in production. Here's how it works and where the sharp edges are.

The trade-off it replaces

Before .NET 11, you had two options for a hub protected by bearer tokens:

Neither is great for a connection meant to stay open for hours. Authentication refresh gives you a third option: keep the connection open, but let the client periodically hand over a fresh token that the server re-validates and swaps in.

// NOTE

This is specific to persistent connections authenticated with bearer tokens (or anything else that reports an expiration). Windows authentication doesn't report a token lifetime and isn't tracked or refreshed by this feature at all.

Enabling it on the server

Authentication refresh is opt-in per hub, configured through MapHub options. Microsoft's guidance is to enable it alongside CloseOnAuthenticationExpiration, so a connection that fails to refresh in time still gets closed rather than left running on a stale principal:

Program.cs
app.MapHub<DispatchHub>("/hubs/dispatch", options =>
{
    options.CloseOnAuthenticationExpiration = true;
    options.EnableAuthenticationRefresh = true;
});

Once this is set, the negotiate response includes the remaining token lifetime whenever the authentication ticket has an expiration, so the client knows when it needs to act. Without a reported lifetime — for example, an auth scheme that doesn't set exp — no automatic refresh gets scheduled.

Validating a refresh before it's applied

A refresh request goes through the same authentication and authorisation pipeline as the original connection, but you get an extra hook: OnAuthenticationRefresh. It runs after the new token is authenticated but before it replaces the connection's principal, and it's the place to apply business rules a generic JWT validator can't express:

Program.cs
app.MapHub<DispatchHub>("/hubs/dispatch", options =>
{
    options.CloseOnAuthenticationExpiration = true;
    options.EnableAuthenticationRefresh = true;
    options.OnAuthenticationRefresh = context =>
    {
        // Reject a refresh that tries to move the connection to a different tenant.
        if (!context.NewUser.HasClaim("tenant", "northwind-logistics"))
        {
            return Task.FromResult(false);
        }

        return Task.FromResult(true);
    };
});

Returning false rejects the refresh with an HTTP 403, and the connection carries on with whatever principal it already had. This callback is a veto, not an override — it can reject a refresh that would otherwise succeed, but it can't approve one that fails SignalR's built-in check that the refreshed principal maps to the same SignalR user.

// IMPORTANT

A refresh can update roles and claims, but it can never change who the connection belongs to. If the refreshed token resolves to a different user ID, SignalR rejects it outright and the connection keeps its original identity.

If you want to cap how far into the future a refresh can push the expiration — useful when a token's own exp is longer than you're comfortable with for a live connection — set MaximumAuthenticationExpiration:

Example.cs
options.MaximumAuthenticationExpiration = TimeSpan.FromHours(2);

This also matters for tokens that don't set an expiration at all: the cap gives the connection a known lifetime regardless, so the negotiate response has something to report and the client can still schedule automatic refreshes.

Refreshing from the .NET client

On the client, refresh reuses whatever AccessTokenProvider you already configured — each refresh just calls it again to get a current token, rather than replaying the one cached at connection start:

DispatchClient.cs
var connection = new HubConnectionBuilder()
    .WithUrl("https://dispatch.internal/hubs/dispatch", options =>
    {
        options.AccessTokenProvider = GetAccessTokenAsync;
    })
    .WithAuthenticationRefresh(options =>
    {
        options.RefreshBeforeExpiration = TimeSpan.FromMinutes(2);
    })
    .Build();

connection.AuthenticationRefreshed += context =>
{
    logger.LogInformation("Token refreshed, new lifetime {Lifetime}", context.NewTokenLifetime);
    return Task.CompletedTask;
};

connection.AuthenticationRefreshFailed += context =>
{
    logger.LogWarning(context.Exception, "Authentication refresh failed");
    return Task.CompletedTask;
};

await connection.StartAsync();

WithAuthenticationRefresh turns on automatic refresh, which is scheduled ahead of the reported expiration by RefreshBeforeExpiration (five minutes by default). If you'd rather trigger it yourself — say, right after your token acquisition library hands you a freshly minted token with different claims — call RefreshAuthenticationAsync directly:

Example.cs
TimeSpan? newLifetime = await connection.RefreshAuthenticationAsync();

It returns the new lifetime the server reported, or null if the server didn't report one.

Refreshing from the JavaScript client

The TypeScript/JavaScript client mirrors the .NET API, built on top of accessTokenFactory:

dispatch-client.js
const connection = new signalR.HubConnectionBuilder()
    .withUrl("/hubs/dispatch", {
        accessTokenFactory: () => getAccessToken()
    })
    .withAuthenticationRefresh({
        refreshBeforeExpirationInMilliseconds: 120000
    })
    .build();

connection.onAuthenticationRefreshed(context => {
    console.log(`Refreshed, new lifetime: ${context.newTokenLifetimeInSeconds}s`);
});

connection.onAuthenticationRefreshFailed(context => {
    console.error("Authentication refresh failed", context.error);
});

Manual refresh is connection.refreshAuthentication(), which resolves with the new lifetime in seconds. Both clients default enableAutoRefresh/EnableAutoRefresh to true, so unless you've got a reason to drive refreshes entirely by hand, the defaults will do the sensible thing as long as the server reports a token lifetime.

Reacting to a refresh inside the hub

If you need server-side code to run when a refresh lands — notifying the client, updating a presence store, logging an audit event — override OnAuthenticationRefreshedAsync:

Hubs/DispatchHub.cs
public class DispatchHub : Hub
{
    public override Task OnAuthenticationRefreshedAsync()
    {
        return Clients.Caller.SendAsync("AuthenticationRefreshed", Context.UserIdentifier);
    }
}

Context.User inside this override — and in every hub method invoked afterwards — reflects the refreshed principal. Anything already mid-flight when the refresh happens keeps the Context.User it started with; only later invocations see the update.

What doesn't change

However tempting it is to think of a refresh as "reconnecting without reconnecting," it isn't quite that. Context.UserIdentifier and any routing built on Clients.User(...) are fixed for the lifetime of the connection and a refresh never touches them. If a user's identity genuinely needs to change — not just their roles or claims, but which user the connection represents — the client has to reconnect. Refresh is for keeping an existing identity's credentials current, not for reassigning a connection to someone else.

Common pitfalls

Summary