11.5 - Authentication trong SignalR
Tóm tắt: Xác thực SignalR có một đặc điểm gây hệ quả bảo mật thật: WebSocket API của trình duyệt không cho đặt header tuỳ ý, nên token phải đi qua query string — và query string lọt vào log truy cập, log proxy và header
Referer. Đặc điểm thứ hai còn nguy hiểm hơn vì nó âm thầm: token chỉ được kiểm tra một lần, lúc kết nối. Một kết nối mở 8 tiếng với token 15 phút vẫn hoạt động bình thường suốt 8 tiếng đó — nghĩa là thu hồi quyền không có hiệu lực cho tới khi kết nối đứt. Đó là lý do phân quyền phải được kiểm tra trong từng Hub method, không chỉ ở[Authorize]trên lớp.
Mục tiêu bài học
Sau bài này bạn có thể:
- Cấu hình JWT đọc token từ query string đúng cách.
- Giảm rủi ro token lọt vào log.
- Xử lý token hết hạn trong kết nối dài.
- Kiểm tra quyền trong Hub method.
- Tự động gán nhóm dựa trên claim.
Nội dung bài học
11.5.1 — Token qua query string
options.Events = new JwtBearerEvents
{
OnMessageReceived = context =>
{
var accessToken = context.Request.Query["access_token"];
// CHỈ cho đường dẫn hub — không áp dụng cho API thường
if (!string.IsNullOrEmpty(accessToken)
&& context.HttpContext.Request.Path.StartsWithSegments("/hubs"))
{
context.Token = accessToken;
}
return Task.CompletedTask;
}
};Điều kiện StartsWithSegments("/hubs") không phải tuỳ chọn. Bỏ nó đi là cho phép mọi endpoint nhận token qua query string — và khi đó mọi token đều nằm trong log truy cập, lịch sử trình duyệt và header Referer khi người dùng bấm link ra ngoài.
Vì sao phải qua query string: WebSocket API của trình duyệt chỉ cho phép đặt URL và subprotocol, không cho header tuỳ ý. SignalR đành chuyển token sang query string.
Ba cách giảm rủi ro:
1. Loại đường dẫn hub khỏi log truy cập:
app.UseSerilogRequestLogging(options =>
{
options.GetLevel = (ctx, _, _) =>
ctx.Request.Path.StartsWithSegments("/hubs")
? LogEventLevel.Verbose // không ghi ở mức thông thường
: LogEventLevel.Information;
});Và cấu hình tương tự ở reverse proxy — Nginx ghi query string vào access.log theo mặc định.
2. Token riêng, sống rất ngắn cho SignalR. Client gọi /api/auth/hub-token lấy một token 60 giây chỉ dùng để kết nối. Lọt vào log thì nó cũng đã hết hạn.
3. Bỏ qua negotiate và dùng subprotocol — phức tạp hơn, hiếm khi đáng.
Trong thực tế, cách 1 cộng access token TTL ngắn (bài 10.2) là cân bằng hợp lý.
11.5.2 — Bảo vệ Hub
[Authorize]
public sealed class NotificationHub(IPresenceTracker presence) : Hub<INotificationClient>
{
public override async Task OnConnectedAsync()
{
var userId = Context.UserIdentifier;
var tenantId = Context.User?.FindFirstValue("tenant_id");
if (userId is null || tenantId is null)
{
Context.Abort(); // không đủ thông tin -> đóng ngay
return;
}
// SERVER tự gán nhóm — client không tự chọn được
await Groups.AddToGroupAsync(Context.ConnectionId, $"tenant-{tenantId}");
if (Context.User!.IsInRole(CrmRoles.Manager))
await Groups.AddToGroupAsync(Context.ConnectionId, $"managers-{tenantId}");
await presence.ConnectAsync(userId, Context.ConnectionId);
await base.OnConnectedAsync();
}
}app.MapHub<NotificationHub>("/hubs/notifications").RequireAuthorization();Server tự gán nhóm là điểm quan trọng nhất trong đoạn này. So sánh với cách để client tự gọi:
// NGUY HIEM — client tu chon nhom
public async Task JoinTenantGroup(string tenantId)
{
await Groups.AddToGroupAsync(Context.ConnectionId, $"tenant-{tenantId}");
}Client gọi JoinTenantGroup("tenant-cua-nguoi-khac") và nhận mọi thông báo của tenant đó. Đây là IDOR (bài 10.6) dưới dạng SignalR, và nó dễ bị bỏ sót hơn vì Hub method không trông giống một endpoint.
Nếu vẫn cần client tự tham gia nhóm, phải kiểm tra quyền:
public async Task JoinDealRoom(Guid dealId)
{
await using var scope = _scopeFactory.CreateAsyncScope();
var deals = scope.ServiceProvider.GetRequiredService<IDealRepository>();
var canAccess = await deals.CanUserAccessAsync(
dealId, Context.UserIdentifier!, Context.ConnectionAborted);
if (!canAccess)
throw new HubException("Không có quyền truy cập");
await Groups.AddToGroupAsync(Context.ConnectionId, $"deal-{dealId}");
}Kèm theo đó là lợi ích phụ: server tự gán nhóm thì client không cần join lại sau reconnect (bài 11.4) — OnConnectedAsync chạy lại và tự lo.
11.5.3 — Token hết hạn giữa kết nối
Đây là đặc điểm ít người biết và hậu quả thì nghiêm trọng:
SignalR kiểm tra token một lần, lúc thiết lập kết nối. Sau đó không kiểm tra lại.
Kết nối mở 8 tiếng với access token 15 phút vẫn hoạt động suốt 8 tiếng. Nghĩa là:
- Thu hồi quyền không có hiệu lực trên kết nối đang mở.
- Khoá tài khoản không ngắt kết nối của người đó.
- Đăng xuất ở thiết bị khác không ảnh hưởng gì.
Ba cách xử lý:
1. Đóng kết nối khi token hết hạn — client tự làm:
setTimeout(async () => {
await this.connection.stop();
await this.connection.start(); // ket noi lai voi token moi
}, tokenLifetimeMs - 60_000);2. Kiểm tra SecurityStamp định kỳ ở server — dùng lại cơ chế ở bài 10.8:
public sealed class SecurityStampCheckFilter(IUserService users) : IHubFilter
{
public async ValueTask<object?> InvokeMethodAsync(
HubInvocationContext context, Func<HubInvocationContext, ValueTask<object?>> next)
{
var userId = context.Context.UserIdentifier;
var tokenStamp = context.Context.User?.FindFirstValue("sstamp");
if (userId is null || !await users.IsStampValidAsync(userId, tokenStamp))
{
context.Context.Abort();
throw new HubException("Phiên không còn hiệu lực");
}
return await next(context);
}
}Nó chỉ chạy khi client gọi một method, nên không bắt được kết nối chỉ nghe.
3. Chủ động ngắt khi thu hồi quyền — chắc chắn nhất:
public async Task RevokeAccessAsync(string userId, CancellationToken ct)
{
await _users.RevokeAsync(userId, ct);
await _refreshTokens.RevokeAllForUserAsync(userId, "access-revoked", ct);
// Ngắt mọi kết nối SignalR của người đó
foreach (var connectionId in await _presence.GetConnectionsAsync(userId, ct))
await _hubContext.Clients.Client(connectionId).ForceDisconnect();
}Với hệ thống có yêu cầu bảo mật cao, cách 3 là bắt buộc. Với hệ thống thông thường, cách 1 là đủ và đơn giản nhất.
11.5.4 — Phân quyền chi tiết
[Authorize]
public sealed class AdminHub : Hub<IAdminClient>
{
[Authorize(Roles = CrmRoles.Admin)] // tren TUNG method
public async Task BroadcastSystemMessage(string message)
{
await Clients.All.ReceiveSystemMessage(message);
}
[Authorize(Policy = "CanViewMetrics")]
public async Task<MetricsDto> GetLiveMetrics() => await _metrics.GetAsync();
}[Authorize] áp dụng được trên từng Hub method — không nhiều người biết. Nó hữu ích khi một Hub có cả method thường và method quản trị.
Nhưng nhớ hai giới hạn:
- Nó chỉ kiểm tra claim trong token, nên bị ảnh hưởng bởi vấn đề token cũ ở mục trên.
- Nó không kiểm tra được quyền trên dữ liệu — việc đó vẫn phải làm thủ công trong thân method.
[AllowAnonymous] cũng dùng được, cho method như "lấy số người đang online" mà ai cũng xem được.
11.5.5 — Rà lại code của bạn
Danh sách rà soát xác thực SignalR
- OnMessageReceived chỉ đọc token từ query string cho đường dẫn hub.
- Đường dẫn hub bị loại khỏi log truy cập ở cả ứng dụng và reverse proxy.
- Access token có TTL ngắn, hoặc có token riêng cho SignalR.
- Hub có [Authorize] và MapHub có RequireAuthorization.
- Server tự gán nhóm dựa trên claim, client không tự chọn tên nhóm.
- Mọi Hub method cho phép client tham gia nhóm đều kiểm tra quyền.
- Có cơ chế xử lý token hết hạn trên kết nối dài.
- Thu hồi quyền kéo theo ngắt kết nối SignalR của người dùng đó.
- Hub method nhạy cảm có [Authorize] riêng với vai trò hoặc policy.
- Quyền trên dữ liệu được kiểm tra trong thân method, không chỉ ở attribute.
Bài tập áp dụng
Bài 1 — Token nằm trong access log của proxy
Kết nối SignalR với token qua query string và tìm token đó trong access.log của Nginx. Loại đường dẫn hub khỏi log và kiểm chứng lại.
Tiêu chí hoàn thành: bạn kể được ba nơi token lọt vào ngoài log của Nginx.
Gợi ý và lời giải — Bài 1
Gợi ý. API trình duyệt cho WebSocket không cho đặt header tuỳ chỉnh, nên token buộc phải đi vào URL. Mà URL thì được ghi lại ở rất nhiều nơi.
Lời giải — tìm token trong log:
const connection = new signalR.HubConnectionBuilder()
.withUrl('/hubs/notifications', { accessTokenFactory: () => token })
.build();SignalR đưa token vào query string:
sudo grep 'hubs/notifications' /var/log/nginx/access.log | tail -1203.0.113.42 - - [25/Sep/2026:09:14:22 +0700]
"GET /hubs/notifications?id=vXk3Lm9Q&access_token=eyJhbGciOiJIUzI1NiIs... HTTP/1.1" 101 0Token nằm nguyên trong log, đọc được bằng mắt thường.
Ba nơi khác token cũng lọt vào:
- Lịch sử trình duyệt và thanh địa chỉ. URL của request WebSocket không hiện trên thanh địa chỉ, nhưng nó nằm trong DevTools, trong HAR file mà người dùng xuất ra khi báo lỗi, và trong bất kỳ ảnh chụp màn hình DevTools nào.
- Header
Referergửi sang bên thứ ba. Nếu trang có tài nguyên từ tên miền khác, một số trình duyệt gửi kèm URL hiện tại — token đi theo tới máy chủ của người khác. - Application Insights, Datadog và mọi công cụ APM. Chúng ghi lại URL đầy đủ của request theo mặc định, nên token vào thẳng hệ thống giám sát, nơi thường có nhiều người truy cập hơn hẳn production.
Cách che ở Nginx:
# Định nghĩa định dạng log có che access_token
log_format masked '$remote_addr - $remote_user [$time_local] '
'"$request_method $masked_uri $server_protocol" '
'$status $body_bytes_sent';
map $request_uri $masked_uri {
~^(?<path>[^?]*)\?(.*)access_token=[^&]*(.*)$ "$path?***";
default $request_uri;
}
location /hubs/ {
access_log /var/log/nginx/access.log masked;
proxy_pass http://crm;
}203.0.113.42 - - [25/Sep/2026:09:20:11 +0700] "GET /hubs/notifications?*** HTTP/1.1" 101 0Và che ở phía ứng dụng:
builder.Services.AddApplicationInsightsTelemetryProcessor<MaskTokenProcessor>();
builder.Services.Configure<RequestLoggingOptions>(o =>
{
o.GetLevel = (ctx, _, ex) =>
ctx.Request.Path.StartsWithSegments("/hubs")
? LogEventLevel.Verbose // không ghi ở mức thông thường
: LogEventLevel.Information;
// KHÔNG bao gồm RequestQuery
});Cách triệt để hơn: đừng để token sống lâu trong URL.
options.Events = new JwtBearerEvents
{
OnMessageReceived = ctx =>
{
var token = ctx.Request.Query["access_token"];
var path = ctx.HttpContext.Request.Path;
// CHỈ cho đường dẫn hub — không áp dụng cho API thường
if (!string.IsNullOrEmpty(token) && path.StartsWithSegments("/hubs"))
ctx.Token = token;
return Task.CompletedTask;
}
};Kèm access token thời hạn ngắn (5 phút) dành riêng cho việc mở kết nối. Token lọt vào log thì cũng hết hạn trước khi ai kịp dùng — cách giảm thiệt hại thực tế nhất, vì bạn không thể xoá hết mọi nơi URL đã đi qua.
Bài 2 — Client tự chọn nhóm và nghe được tổ chức khác
Cài JoinTenantGroup(string tenantId) không kiểm tra quyền, gọi nó với tenant id của người khác, và xác nhận bạn nhận được thông báo của tenant đó.
Tiêu chí hoàn thành: bạn nêu được vì sao lỗ hổng này không bị [Authorize] trên Hub chặn lại.
Gợi ý và lời giải — Bài 2
Gợi ý. [Authorize] trả lời "người này đã đăng nhập chưa". Nó không đọc tham số của phương thức.
Lời giải — Hub dễ tổn thương:
[Authorize]
public class NotificationHub : Hub<INotificationClient>
{
public Task JoinTenantGroup(string tenantId)
=> Groups.AddToGroupAsync(Context.ConnectionId, $"tenant:{tenantId}");
}// Người dùng của tenant "acme" gọi với tenant id của công ty khác
await connection.invoke('JoinTenantGroup', 'globex');(server đẩy thông báo cho tenant globex)
-> client của acme NHẬN ĐƯỢCCâu trả lời cho tiêu chí. [Authorize] chạy trước khi phương thức bắt đầu và chỉ kiểm tra danh tính: người gọi có token hợp lệ không. Nó hoàn toàn không biết phương thức nhận tham số gì, càng không biết tenantId đó có thuộc về người gọi hay không.
Đây đúng cùng một lớp lỗi với IDOR ở bài 10.6: xác thực đúng, phân quyền theo tài nguyên thì không có. Chỉ khác là ở đây "tài nguyên" là một nhóm SignalR.
Bản sửa — server tự gán, không nhận tham số:
[Authorize]
public class NotificationHub : Hub<INotificationClient>
{
public override async Task OnConnectedAsync()
{
var tenantId = Context.User!.FindFirstValue("tenant_id");
if (string.IsNullOrEmpty(tenantId))
{
Context.Abort(); // không đủ thông tin -> đóng ngay
return;
}
// SERVER tự gán nhóm — client không tự chọn được
await Groups.AddToGroupAsync(Context.ConnectionId, $"tenant:{tenantId}");
await base.OnConnectedAsync();
}
}Không còn phương thức nào nhận tenantId, nên không còn gì để giả mạo. Và vì OnConnectedAsync chạy mỗi lần kết nối, nhóm tự khôi phục sau reconnect — giải quyết luôn vấn đề ở bài 11.4.
Với nhóm mà người dùng thật sự được chọn — ví dụ mở một deal cụ thể — thì vẫn nhận tham số, nhưng phải kiểm quyền:
public async Task JoinDealRoom(Guid dealId)
{
var deal = await _deals.GetAsync(dealId, Context.ConnectionAborted);
if (deal is null) throw new HubException("Deal không tồn tại");
var authorized = await _authz.AuthorizeAsync(Context.User!, deal, "DealAccess");
if (!authorized.Succeeded) throw new HubException("Bạn không có quyền vào phòng này");
await Groups.AddToGroupAsync(Context.ConnectionId, $"deal:{dealId}");
}Quy tắc chung cho mọi Hub method:
| Tham số đến từ | Xử lý |
|---|---|
| Claim trong token | Tin được — đã ký |
| Tham số do client truyền | Kiểm quyền trước khi dùng |
Và đừng quên chiều gửi đi. Kiểm tra lúc join là chưa đủ nếu có chỗ nào đó gửi thẳng theo tham số client đưa lên:
// SAI — client chọn luôn người nhận
public Task SendTo(string groupName, string message)
=> Clients.Group(groupName).Receive(message);Phương thức này cho phép bất kỳ ai phát tin vào bất kỳ nhóm nào. Việc gửi nên do server khởi xướng qua IHubContext, không do client gọi.
Bài 3 — Token hết hạn nhưng kết nối vẫn sống
Đặt access token 60 giây, giữ kết nối SignalR mở 5 phút, và xác nhận nó vẫn hoạt động. Thu hồi quyền của người dùng đó và xác nhận kết nối vẫn nhận tin.
Tiêu chí hoàn thành: bạn nêu được vì sao SignalR không tự ngắt khi token hết hạn, và chọn được cách xử lý phù hợp.
Gợi ý và lời giải — Bài 3
Gợi ý. Token được kiểm một lần, lúc bắt tay. Sau đó không còn request HTTP nào để kiểm lại.
Lời giải — kiểm chứng:
expires: DateTime.UtcNow.AddSeconds(60)await connection.start();
setInterval(() => connection.invoke('Ping').then(console.log), 30000);09:00:00 start -> kết nối thành công
09:00:30 Ping -> pong
09:01:30 Ping -> pong <- token đã hết hạn 30 giây trước
09:04:30 Ping -> pong <- vẫn chạy sau 5 phútKết nối sống bình thường, và mọi thông báo vẫn tới.
Vì sao SignalR không tự ngắt. Với WebSocket, xác thực xảy ra đúng một lần trong lượt bắt tay HTTP ban đầu. Sau khi nâng cấp lên WebSocket, không còn request HTTP nào nữa — chỉ còn một kênh nhị phân hai chiều. Không có chỗ nào để đính kèm token mới, và cũng không có điểm nào trong vòng đời kết nối để framework chen vào kiểm lại.
Đây là đánh đổi cố hữu của giao thức, không phải thiếu sót của SignalR. Và nó tạo ra hai rủi ro cần xử lý riêng:
1. Token hết hạn -> kết nối sống lâu hơn thời hạn token
2. Quyền bị thu hồi -> người đã bị gỡ quyền vẫn nhận thông báoBa cách xử lý, chọn theo mức nhạy cảm:
1. Đặt hạn cho chính kết nối — đơn giản nhất.
builder.Services.AddSignalR(o =>
o.ClientTimeoutInterval = TimeSpan.FromSeconds(30));
app.MapHub<NotificationHub>("/hubs/notifications")
.RequireAuthorization();
// Đóng kết nối khi token hết hạn
public override async Task OnConnectedAsync()
{
var exp = Context.User!.FindFirstValue("exp");
if (long.TryParse(exp, out var unix))
{
var remaining = DateTimeOffset.FromUnixTimeSeconds(unix) - DateTimeOffset.UtcNow;
if (remaining > TimeSpan.Zero)
_ = CloseAfterAsync(Context.ConnectionId, remaining);
}
await base.OnConnectedAsync();
}Client bắt sự kiện đóng, lấy token mới rồi kết nối lại — SignalR đã có sẵn cơ chế tự kết nối lại nên người dùng không thấy gì.
2. Làm mới token chủ động ở client — mượt hơn.
const connection = new signalR.HubConnectionBuilder()
.withUrl('/hubs/notifications', {
accessTokenFactory: async () => {
// Tự động làm mới token nếu sắp hết hạn
if (isExpiringSoon(currentToken)) currentToken = await refreshToken();
return currentToken;
}
})
.withAutomaticReconnect()
.build();accessTokenFactory được gọi lại mỗi lần kết nối lại, nên token luôn mới sau mỗi lần đứt. Nó không giúp gì cho một kết nối đang sống liên tục — vì vậy nên dùng kèm cách 1.
3. Ngắt kết nối khi thu hồi quyền — cho trường hợp nhạy cảm.
public async Task DeactivateUserAsync(Guid userId, CancellationToken ct)
{
// ... vô hiệu hoá tài khoản, thu hồi refresh token ...
// Ngắt mọi kết nối SignalR của người đó
await _hub.Clients.User(userId.ToString()).ForceDisconnect();
}Client nhận ForceDisconnect thì tự gọi connection.stop() và chuyển về màn hình đăng nhập. Cần client hợp tác, nên với người dùng cố tình sửa client thì không chặn được — muốn chắc chắn thì phải giữ bảng kết nối phía server và đóng từ đó.
Khuyến nghị: cách 1 + 2 cho phần lớn hệ thống. Thêm cách 3 khi thông báo mang dữ liệu nhạy cảm và việc "người vừa bị gỡ quyền còn nghe thêm vài phút" là không chấp nhận được.
Tự kiểm tra
Vì sao token SignalR phải đi qua query string?
Vì WebSocket API của trình duyệt chỉ cho phép đặt URL và subprotocol, không cho header tuỳ ý, nên không gửi được Authorization header. Hệ quả là token nằm trong query string và lọt vào log truy cập, log proxy và header Referer.
Vì sao điều kiện StartsWithSegments trong OnMessageReceived là bắt buộc?
Bỏ nó đi là cho phép mọi endpoint nhận token qua query string, và khi đó mọi token đều nằm trong log truy cập, lịch sử trình duyệt và header Referer khi người dùng bấm link ra ngoài. Chỉ đường dẫn hub mới cần cơ chế này.
Vì sao server nên tự gán nhóm thay vì để client gọi?
Vì client gọi được với tên nhóm của người khác và nhận mọi thông báo của nhóm đó; đó là IDOR dưới dạng SignalR, dễ bị bỏ sót hơn vì Hub method không trông giống endpoint. Lợi ích phụ là client không cần join lại sau reconnect vì OnConnectedAsync tự lo.
SignalR kiểm tra token bao nhiêu lần?
Một lần duy nhất, lúc thiết lập kết nối. Sau đó không kiểm tra lại, nên kết nối mở 8 tiếng với token 15 phút vẫn hoạt động suốt 8 tiếng. Thu hồi quyền và khoá tài khoản đều không có hiệu lực trên kết nối đang mở.
Có những cách nào xử lý token hết hạn trên kết nối dài?
Client tự đóng và mở lại kết nối trước khi token hết hạn. Hoặc dùng HubFilter kiểm tra SecurityStamp mỗi lần client gọi method, nhưng nó không bắt được kết nối chỉ nghe. Hoặc chắc chắn nhất là chủ động ngắt mọi kết nối của người dùng khi thu hồi quyền.
[Authorize] dùng được ở đâu trong SignalR?
Trên cả lớp Hub lẫn từng Hub method, và AllowAnonymous cũng dùng được cho method công khai. Nhưng nó chỉ kiểm tra claim trong token nên chịu ảnh hưởng của vấn đề token cũ, và không kiểm tra được quyền trên dữ liệu; việc đó vẫn phải làm trong thân method.
Kết luận
Ba điều đáng nhớ nhất:
- Token nằm trong query string — loại đường dẫn hub khỏi log và giữ TTL ngắn.
- Token chỉ được kiểm tra một lần. Thu hồi quyền không ngắt kết nối đang mở.
- Server tự gán nhóm theo claim. Client tự chọn tên nhóm là IDOR.
Tham khảo
- Authentication and authorization in ASP.NET Core SignalR
- Security considerations in ASP.NET Core SignalR
- Manage users and groups in SignalR
- Configure JWT bearer authentication in ASP.NET Core
Điều hướng
- Bài trước: 11.4 — Client-side Integration
- Bài tiếp theo: 11.6 — Groups và Targeting
- Về module: Trang mục lục