Mert Özen Further With Every Line
Backend Real-time Applications — #06

Authentication, Authorization and Security in SignalR: JWT, the access_token Query String and Hub Method Policies

Mert Özen Nov 8, 2026 11 min 3 views
Authentication, Authorization and Security in SignalR: JWT, the access_token Query String and Hub Method Policies

SignalR authenticates a user once, when connecting; by default the connection stays open even after the token expires or a role changes. access_token via OnMessageReceived, tokens leaking into logs, accessTokenFactory, hub and method policies, resource-based checks with HubInvocationContext, rate limiting with IHubFilter, message size, Origin, and CloseOnAuthenticationExpiration.

Version note: The code in this post was built and run with .NET 10, Microsoft.AspNetCore.Authentication.JwtBearer 10.0.14, Angular 22.2 and @microsoft/signalr 10.0.11.

Guess First

A user connects to SignalR with a JWT (JSON Web Token) valid for 5 minutes and leaves the tab open for two hours. In minute six they call a hub method. Is the call rejected with a 401, does the connection drop, or does it work as if nothing happened? Meanwhile, suppose an administrator has taken a role away from the user; can they still call the method that requires that role?

In HTTP (Hypertext Transfer Protocol) APIs (Application Programming Interface) every request shows the token again, so these questions never come up there. On a long-lived connection, however, authentication happens once, when connecting. In this post we see all of its consequences through real experiments.

Protecting the Hub with JWT

// demo: a random signing key on every process start (in reality it comes from a key vault)
SymmetricSecurityKey signingKey = new(RandomNumberGenerator.GetBytes(32));

builder.Services.AddAuthentication(JwtBearerDefaults.AuthenticationScheme)
    .AddJwtBearer(options =>
    {
        options.TokenValidationParameters = new TokenValidationParameters
        {
            ValidIssuer = "bookstore-dev",
            ValidAudience = "bookstore-api",
            IssuerSigningKey = signingKey,
            ClockSkew = TimeSpan.Zero
        };
        options.Events = new JwtBearerEvents
        {
            OnMessageReceived = context =>
            {
                // the browser cannot add headers to WebSocket and SSE requests; the token arrives in the query string
                string? accessToken = context.Request.Query["access_token"];
                if (!string.IsNullOrEmpty(accessToken) && context.HttpContext.Request.Path.StartsWithSegments("/hubs"))
                {
                    context.Token = accessToken;
                }
                return Task.CompletedTask;
            }
        };
    });

Issuing and validating the token is the same as in the "Authentication with JWT" post of the ASP.NET Core Web API series; the only difference is OnMessageReceived. The JWT bearer handler normally only looks for the token in the Authorization header. In this event we say "if the path starts with /hubs and the query string has an access_token, use it". The path check matters: leave it out and every endpoint of the application accepts tokens from the query string, which makes it easy for tokens to spread into URLs (Uniform Resource Locators), browser history and logs.

ClockSkew = TimeSpan.Zero is for the demo: the default 5-minute tolerance would delay the expiry tests below by 5 minutes. With the hub marked [Authorize], a client that sends no token is rejected already at the negotiate stage:

anonymous start failed: Failed to complete negotiation with the server: Error: Unauthorized: Status code '401'

Why the Query String? Browsers and Token Logging

The browser's WebSocket and EventSource APIs do not allow adding custom headers. This is a constraint of the browser API, not of SignalR or the WebSocket protocol. That is why SignalR's JavaScript client adds the token to the URL for the WebSocket and SSE (Server-Sent Events) transports in the browser. When one client connects to the same application from a browser and another from Node.js with the same @microsoft/signalr package, the server's request log shows:

Request starting HTTP/1.1 GET http://localhost:17606/hubs/orders?id=fWuk-GSN_kJMiLOlWysGdQ&access_token=eyJhbGciOiJIUzI1NiIsInR5cCI6IkpXVCJ9.eyJhdWQiOiJib29rc3RvcmUtYXBpIiwiaXNzIjoiYm9...
Request starting HTTP/1.1 GET http://localhost:17606/hubs/orders?id=BgWsww_AIGw0uiUtU-VCXA - - -

The first line is from the browser: the token landed in ASP.NET Core's default request log as is (I shortened the line; the log has the full token). The second line is from Node: non-browser clients send the token in the Authorization header, so it is not in the URL. HTTPS (HTTP Secure) encrypts the query string on the wire, but logs are not encrypted; anyone who collects logs can act as that user for the token's lifetime.

There are three countermeasures. The first is to turn off the request log or lower its level: "Microsoft.AspNetCore.Hosting.Diagnostics": "Warning" in appsettings.json. When I ran the same application with this setting, no Request starting lines were written at all. If you want a request log, write your own middleware that masks the access_token parameter. The second is the access logs of reverse proxies and load balancers: Nginx's default log format also writes the full URL, so apply the same measure there. The third is to keep token lifetimes short; the value of a leaked token is proportional to its lifetime.

Angular: accessTokenFactory

import { Injectable } from '@angular/core';

@Injectable({ providedIn: 'root' })
export class AuthTokenService {
  private token: string | null = null;
  private expiresAt = 0;

  // in a real app this is the OIDC library's "give me a valid token, refresh if needed" call
  async getAccessToken(): Promise<string> {
    if (!this.token || Date.now() > this.expiresAt - 30_000) {
      const response = await fetch('http://localhost:17606/dev/token?user=ada&role=customer&seconds=300', {
        method: 'POST',
      });
      this.token = ((await response.json()) as { token: string }).token;
      this.expiresAt = Date.now() + 300_000;
    }
    return this.token;
  }
}
private readonly connection: HubConnection = new HubConnectionBuilder()
  .withUrl('http://localhost:17606/hubs/orders', {
    // called on negotiate and on every reconnect; not called again while the connection is open
    accessTokenFactory: () => this.auth.getAccessToken(),
  })
  .configureLogging(LogLevel.Warning)
  .build();

We give accessTokenFactory not a fixed string but a function that returns a valid token every time it is called. The function is called on negotiate and on every reconnect attempt; it is not called while the connection is open. So refreshing the token only affects the next connection, not the open one. If your OIDC (OpenID Connect) library already contains "refresh if it is about to expire" logic, calling it here is enough; an HTTP interceptor is no use for adding the token, because it does not see SignalR's WebSocket requests.

In the browser, the screen of the component connected as Ada (order 42 belongs to Ada, 43 to Alan):

ada (customer), token expires at 02:44:18

watching order 42

order 43: Failed to invoke 'WatchOrder' because user is unauthorized

order 42: Packed

The last line is the result of Grace, a warehouse worker, packing 42 from another client. We will come to why 43 was rejected in a moment.

Policies at Hub and Method Level

[Authorize]
public sealed class OrderHub : Hub<IOrderClient>
{
    public string WhoAmI() =>
        $"{Context.UserIdentifier} ({Context.User!.FindFirstValue(ClaimTypes.Role)}), " +
        $"token expires at {DateTimeOffset.FromUnixTimeSeconds(long.Parse(Context.User!.FindFirstValue("exp")!)):HH:mm:ss}";

    [Authorize(Policy = "OrderOwner")]
    public Task WatchOrder(int orderId) =>
        Groups.AddToGroupAsync(Context.ConnectionId, $"order-{orderId}");

    [Authorize(Policy = "Warehouse")]
    public Task MarkPacked(int orderId) =>
        Clients.Group($"order-{orderId}").OrderStatusChanged(orderId, "Packed");

    public int Echo(string text) => text.Length;
}

builder.Services.AddAuthorization(options =>
{
    options.AddPolicy("Warehouse", policy => policy.RequireRole("warehouse"));
    options.AddPolicy("OrderOwner", policy => policy.AddRequirements(new OrderOwnerRequirement()));
});

The [Authorize] on the class protects the connection itself: negotiate without a token is a 401. The [Authorize(Policy = ...)] on a method is evaluated on every call. The policies are exactly the same as in the Authorization post of the Web API series; SignalR uses them like HTTP endpoints do. From the Node client, with Ada the customer and Grace the warehouse worker:

WhoAmI() -> "ada (customer), token expires at 02:42:13"
WatchOrder(42) -> null
WatchOrder(43) failed: Failed to invoke 'WatchOrder' because user is unauthorized
MarkPacked(42) failed: Failed to invoke 'MarkPacked' because user is unauthorized
ada <- OrderStatusChanged(42, Packed)
MarkPacked(42) -> null

Ada could not call MarkPacked; when Grace called it, Ada, who watches 42, received the update. An unauthorized call does not close the connection; only that call returns an error.

Resource-Based Authorization: Only Your Own Order's Group

This was the open door from the previous post: we make up the group names, and anyone calling WatchOrder(43) could join the group of order 43. A role check is not enough here; Ada and Alan are both "customer". The decision depends on the call's argument:

public sealed class OrderOwnerRequirement : IAuthorizationRequirement;

public sealed class OrderOwnerHandler(OrderOwnership ownership)
    : AuthorizationHandler<OrderOwnerRequirement, HubInvocationContext>
{
    protected override Task HandleRequirementAsync(
        AuthorizationHandlerContext context, OrderOwnerRequirement requirement, HubInvocationContext resource)
    {
        // the method's first argument is the order number; warehouse staff may watch any order, a customer only their own
        if (resource.HubMethodArguments is [int orderId, ..] &&
            (context.User.IsInRole("warehouse") || ownership.IsOwner(orderId, context.User.FindFirstValue(ClaimTypes.NameIdentifier))))
        {
            context.Succeed(requirement);
        }
        return Task.CompletedTask;
    }
}

When SignalR authorizes a hub method, it passes the handler a HubInvocationContext as the resource: the caller's connection, the method name and the arguments. We take the first argument with a list pattern ([int orderId, ..]) and check ownership. OrderOwnership is a fixed dictionary here (42 → ada, 43 → alan); in a real application it would be a database query running on every WatchOrder call, so it has to be fast. The handler is registered as a singleton; if you need a scoped DbContext, register the handler as scoped.

IHubFilter and Limits: Rate Limiting, Message Size, Origin

For shared checks that must run on every hub call (audit logs, rate limiting, validation) there is IHubFilter; the SignalR counterpart of an MVC (Model-View-Controller) action filter:

public sealed class RateLimitHubFilter(ILogger<RateLimitHubFilter> logger) : IHubFilter
{
    private const string LimiterKey = "rate-limiter";

    public Task OnConnectedAsync(HubLifetimeContext context, Func<HubLifetimeContext, Task> next)
    {
        // 5 calls per 10 seconds per connection
        context.Context.Items[LimiterKey] = new TokenBucketRateLimiter(new TokenBucketRateLimiterOptions
        {
            TokenLimit = 5,
            TokensPerPeriod = 5,
            ReplenishmentPeriod = TimeSpan.FromSeconds(10),
            QueueLimit = 0
        });
        return next(context);
    }

    public async ValueTask<object?> InvokeMethodAsync(
        HubInvocationContext invocationContext, Func<HubInvocationContext, ValueTask<object?>> next)
    {
        TokenBucketRateLimiter limiter = (TokenBucketRateLimiter)invocationContext.Context.Items[LimiterKey]!;
        using RateLimitLease lease = limiter.AttemptAcquire();
        if (!lease.IsAcquired)
        {
            logger.LogWarning("{User} rate limited on {Method}",
                invocationContext.Context.UserIdentifier, invocationContext.HubMethodName);
            throw new HubException("Too many requests, slow down.");
        }

        long started = Stopwatch.GetTimestamp();
        object? result = await next(invocationContext);
        logger.LogInformation("{User} called {Method} in {Elapsed:F1} ms",
            invocationContext.Context.UserIdentifier, invocationContext.HubMethodName,
            Stopwatch.GetElapsedTime(started).TotalMilliseconds);
        return result;
    }

    public Task OnDisconnectedAsync(HubLifetimeContext context, Exception? exception, Func<HubLifetimeContext, Exception?, Task> next)
    {
        (context.Context.Items[LimiterKey] as IDisposable)?.Dispose();
        return next(context, exception);
    }
}

builder.Services.AddSignalR(options =>
{
    options.MaximumReceiveMessageSize = 32 * 1024;
    options.AddFilter<RateLimitHubFilter>();
});

ASP.NET Core's rate limiting middleware runs per HTTP request; a WebSocket connection is a single HTTP request, so it cannot see the hundreds of hub calls inside it. That is why we put the limit in the filter, with one TokenBucketRateLimiter per connection, kept in Context.Items. Grace's token bucket had started with one MarkPacked; the six Echo calls that followed:

Echo(hello) -> 5
Echo(hello) -> 5
Echo(hello) -> 5
Echo(hello) -> 5
Echo(hello) failed: An unexpected error occurred invoking 'Echo' on the server. HubException: Too many requests, slow down.
Echo(hello) failed: An unexpected error occurred invoking 'Echo' on the server. HubException: Too many requests, slow down.

A detail: unauthorized calls (Ada's MarkPacked) never reached the filter and did not show up in the audit log; authorization runs before the filter. If you want to count unauthorized attempts too, you have to do it in the authorization handler or in the logs.

Message size: when Ada sent a 40,000-character Echo, all I saw on the client was "Connection closed with an error."; raising the server log to Debug revealed the reason:

System.IO.InvalidDataException: The maximum message size of 32768B was exceeded. The message size can be configured in AddHubOptions.

A message exceeding MaximumReceiveMessageSize closes not just that call but the whole connection. Rather than raising the limit, sending large data with an ordinary HTTP upload is usually the right fix.

Finally, Origin. SignalR's negotiate request is protected by CORS (Cross-Origin Resource Sharing), but as we saw in the second post of the series, the browser does not apply CORS to WebSocket requests. When I gave app.UseWebSockets an AllowedOrigins list before MapHub, SignalR's WebSocket request went through that check too. Two requests with a valid token and connection token, differing only in the Origin header:

Origin: https://evil.example
HTTP/1.1 403 Forbidden
Origin: http://localhost:4200
HTTP/1.1 101 Switching Protocols

What Happens When the Token Expires?

I connected with a 4-second token, waited 7 seconds and called the same method again:

connected at 02:37:53
WhoAmI() -> "ada (customer), token expires at 02:37:57"
after 7 s at 02:38:00
WhoAmI() -> "ada (customer), token expires at 02:37:57"

The answer to the opening question: nothing happened. SignalR caches the user validated when the connection was established (Context.User) for the lifetime of the connection and does not recheck its expiry, revocation or role changes. Method-level [Authorize(Roles = ...)] also looks at this cached user; so a user whose role was taken away can keep calling that method until their connection drops. Microsoft's documentation says so explicitly: this behaviour applies to all authentication schemes, cookies included.

The way to change that in .NET 10 is CloseOnAuthenticationExpiration:

app.MapHub<OrderHub>("/hubs/orders", options =>
    options.CloseOnAuthenticationExpiration = args.Contains("--close-on-expiry"));

I started the server with --close-on-expiry and repeated the same test:

connected at 02:38:06
WhoAmI() -> "ada (customer), token expires at 02:38:10"
ada closed: no error
after 7 s at 02:38:13
WhoAmI(undefined) failed: Cannot send data if the connection is not in the 'Connected' State.
Connection FqQFMEoU7m-vulpTraBB3Q closing because the authentication token has expired.

The server closed the connection the moment the token expired. On the client this is a drop: with withAutomaticReconnect it reconnects, accessTokenFactory gets a new token, and work continues. The price is the messages missed during the reconnect and the lost group memberships. .NET 11 solves this with "authentication refresh", which renews the token without dropping the connection; we cover it in the last post of the series.

Production Notes

Have a way to reflect permission changes onto connections. When a user's role changes or their account is closed, you must be able to find their connections to close them; sending a "sign out" message to Clients.User(id) and having the client call stop() is the simplest way. For critical operations, rechecking the current permission from the database inside the hub method is safer than trusting the cached Context.User.

Think about token lifetime and connection lifetime together: short-lived tokens with CloseOnAuthenticationExpiration mean a reconnect per user at regular intervals. 10,000 users and 5-minute tokens means 33 reconnects per second on average, including negotiate, authentication and group joins.

Remember that rate limiting and audit filters run on every call; do not go to the database inside them, keep them in memory and fast.

Common Pitfalls

Not checking the path in OnMessageReceived. The whole API starts accepting tokens from the query string.

Forgetting request logs and proxy access logs. Every WebSocket connection from a browser carries the token in the URL.

Trying to add the token with an Angular HTTP interceptor. SignalR's WebSocket and SSE requests do not go through HttpClient; use accessTokenFactory.

Assuming the connection closes when the token expires. The default is to stay open; turn on CloseOnAuthenticationExpiration if you want that.

Protecting group joins with a role alone. Everyone with the same role joins each other's groups; write a resource-based handler that looks at the argument.

Thinking the HTTP rate limiting middleware limits hub calls. On a WebSocket connection it runs only once, on the request that opens the connection; hub calls need a filter.

A Small Experiment

★☆☆ Remove the path check from OnMessageReceived and send a request with ?access_token=... to a protected HTTP endpoint other than /dev/token. See that the request is accepted, then put the check back.

★★☆ Write a small middleware that logs the request path and duration with the access_token value replaced by ***, lower Microsoft.AspNetCore.Hosting.Diagnostics to Warning, and verify that the token does not appear in the log when you connect from the browser.

★★★ With CloseOnAuthenticationExpiration on, add withAutomaticReconnect() on the Angular side, lower the token lifetime to 20 seconds, and watch the connection state for two minutes along with whether the WatchOrder group gets lost. Rebuild the group in onreconnected.

In the next post we treat a dropped connection not as an error but as a normal event: automatic reconnect and a custom retry policy, .NET 8's stateful reconnect, server and client streaming, the MessagePack protocol, and slow clients.