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:
- Leave
CloseOnAuthenticationExpirationoff (the default), and the connection silently outlives the token. Hub methods keep authorising against aContext.Userthat may no longer reflect reality. - Turn it on, and every client has to reconnect on a timer, dropping and re-establishing the connection, re-joining groups, and racing to avoid a gap in message delivery.
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:
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:
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:
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:
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:
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:
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:
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
- Forgetting
CloseOnAuthenticationExpiration. EnablingEnableAuthenticationRefreshon its own doesn't force anything — a client that never refreshes just keeps running on its original token indefinitely, same as before .NET 11. Pair the two options unless you have a specific reason not to. - Assuming
OnAuthenticationRefreshcan approve a cross-user token. It can't. If your reverse proxy or auth provider is capable of minting a token for a different user under the same session, that refresh will be rejected regardless of what your callback returns. - Expecting automatic refresh with no reported expiration. If your auth scheme doesn't set a token lifetime — some custom schemes and Windows auth don't — the client has nothing to schedule against. Set
MaximumAuthenticationExpirationon the server if you want automatic refresh to work reliably regardless of what the token claims. - Not handling
AuthenticationRefreshFailed. A failed refresh doesn't close the connection by itself; ifCloseOnAuthenticationExpirationis set, the connection closes only once the old token's expiration passes. Silently ignoring refresh failures means you find out about a broken token provider only when users start getting disconnected. - Testing only the happy path. The negotiate protocol version matters here — authentication refresh requires protocol version 1 or later. If you're maintaining an older custom client, verify it actually negotiates a compatible version before relying on refresh.
Summary
- SignalR connections have always outlived their access tokens by default; .NET 11's authentication refresh is the first built-in way to update credentials on a live connection without reconnecting.
- Enable it per hub with
EnableAuthenticationRefresh, and pair it withCloseOnAuthenticationExpirationso unrefreshed connections still get closed. OnAuthenticationRefreshlets you veto a refresh with custom logic, but it can never override SignalR's rule that a refresh must map to the same SignalR user.MaximumAuthenticationExpirationbounds how far a refresh can push the expiration out, and gives connections a known lifetime even when the token itself doesn't set one.- Both the .NET and JavaScript clients support automatic refresh ahead of expiration (
WithAuthenticationRefresh/withAuthenticationRefresh) and manual refresh (RefreshAuthenticationAsync/refreshAuthentication). - A refresh updates roles and claims on
Context.User, but neverContext.UserIdentifieror message routing — those are fixed for the connection's lifetime.