SignalR com 404 no negotiate: as 4 causas testadas
404 no negotiate do SignalR testado no .NET 10: URL do cliente sem o prefixo, hub mal mapeado, proxy que muda o caminho e conexão que troca de instância.
Quando o SignalR do ASP.NET Core devolve 404 em /negotiate, o servidor está dizendo que não existe nada naquele caminho para aquela requisição. Isso tem quatro origens possíveis: a URL que o cliente monta não bate com o MapHub, o proxy entrega ao aplicativo um caminho diferente do que o navegador pediu, o cliente é do SignalR antigo, ou a conexão foi criada em uma instância e continuou em outra.
Para separar uma da outra sem palpite, subimos no box um aplicativo .NET 10 (SDK 10.0.403, ASP.NET Core 10.0.14) com dois hubs e testamos cada cenário com curl. Os códigos de status abaixo são a saída real desse teste, que está completo no fim do texto.
O que a negociação faz
Antes de abrir o WebSocket, o cliente JavaScript faz um POST para a URL do hub com /negotiate no fim. O servidor responde com um identificador de conexão e a lista de transportes que aceita (WebSockets, Server-Sent Events, Long Polling). O formato está descrito na especificação de transportes do SignalR, no repositório do ASP.NET Core.
Então o 404 ali acontece antes de qualquer código do hub rodar. Autenticação, CORS e métodos do hub só entram em jogo depois que essa requisição volta com 200. A resposta certa, no nosso teste, começa assim:
{"negotiateVersion":1,"connectionId":"0P31uOGJDS62dmIWnWkGcw","connectionToken":"PkVfEytIc05sZ3MjF_CoBA","availableTransports":[{"transport":"WebSockets","transferFormats":["Text","Binary"]},...
O jeito mais rápido de descobrir de que lado está o erro é copiar a URL do negotiate da aba Rede do navegador e repetir a chamada com curl, trocando só o caminho:
curl -i -X POST "https://SEU-DOMINIO/painel/hubs/pedidos/negotiate?negotiateVersion=1"
Se a URL certa responde 200 (ou 401, quando o hub exige login), o servidor está bem e o problema é a URL que o cliente monta. Se nem a URL certa responde, o problema está no aplicativo ou no proxy.
Os códigos que o teste devolveu
O aplicativo de teste mapeia /hubs/pedidos (aberto) e /hubs/admin (com [Authorize]). A instância A roda com UsePathBase("/painel"), simulando um site publicado em subcaminho; a instância B roda sem prefixo.
- 200: POST em
/painel/hubs/pedidos/negotiatena instância A. Caminho certo. - 200: o mesmo POST sem o
/painel, direto no Kestrel. OUsePathBaseaceita o prefixo, mas não o exige; por isso o erro de prefixo só aparece quando há um proxy roteando por ele. - 404: POST em
/painel/hubs/pedidos/negotiatena instância B, que não temUsePathBase. É o que acontece quando o proxy repassa o prefixo e o aplicativo não sabe dele. - 404:
/painel/hubs/pedido/negotiate, singular no cliente e plural no servidor. - 200:
/painel/Hubs/Pedidos/negotiate. O roteamento do ASP.NET Core não diferencia maiúsculas, então caixa diferente não explica um 404. - 405: GET em vez de POST no negotiate.
- 401: negotiate do hub com
[Authorize]sem estar logado. - 404:
/painel/signalr/negotiate, o caminho que o cliente do SignalR clássico (jQuery) usa. - 404 com o texto
No Connection with that ID: conexão negociada na instância B e continuada na instância A.
A página de solução de problemas de conexão do SignalR, na documentação da Microsoft, lista as mesmas causas para o 404: endpoint errado no cliente, vários servidores sem sessão fixa e ID de conexão que expirou entre o negotiate e a requisição seguinte.
Quando a URL do cliente está errada
O caso mais comum é o JavaScript com caminho absoluto, .withUrl("/hubs/pedidos"). Na máquina do desenvolvedor o site roda na raiz e funciona. Publicado em /painel/, esse caminho aponta para fora do site, e quem responde é outro aplicativo ou a página de erro do proxy.
A correção é deixar o servidor gerar a URL, porque ele conhece o prefixo atual. No teste, um endpoint que devolve Request.PathBase mais o caminho do hub respondeu /painel/hubs/pedidos na instância A. Numa view Razor, o equivalente é:
<script>
const conexao = new signalR.HubConnectionBuilder()
.withUrl("@Url.Content("~/hubs/pedidos")")
.withAutomaticReconnect()
.build();
</script>
Num front-end separado (React, Vue, Angular), use a mesma variável de configuração que define a URL base da API. A documentação do cliente JavaScript mostra o withUrl e as opções de transporte; o pacote atual no npm é o @microsoft/signalr, versão 10.0.11 em 29/10/2026.
Se o 404 vem em /signalr/negotiate, sem o nome do hub no caminho, a página carrega o cliente do SignalR clássico (jquery.signalR.js com $.hubConnection). Ele não conversa com um servidor ASP.NET Core; a Microsoft explica as diferenças entre as duas versões. A saída é trocar o script pelo @microsoft/signalr e reescrever a conexão no formato do HubConnectionBuilder.
Quando o servidor não tem aquele caminho
O aplicativo precisa do AddSignalR e de um MapHub com o mesmo caminho que o cliente usa, conforme a documentação de hubs. No teste, a diferença de uma letra (pedido x pedidos) bastou para o 404.
builder.Services.AddSignalR();
var app = builder.Build();
app.UsePathBase("/painel"); // só se o proxy repassa o prefixo
app.UseRouting(); // depois do UsePathBase
app.UseAuthentication();
app.UseAuthorization();
app.MapHub<PedidosHub>("/hubs/pedidos");
O UsePathBase separa o prefixo em Request.PathBase e deixa o resto em Request.Path, e o roteamento passa a casar /hubs/pedidos. A Microsoft descreve esse uso em configurar o ASP.NET Core para trabalhar com proxies e pede que UseRouting venha depois dele.
Para conferir quais rotas o aplicativo realmente registrou, ligue o log de Microsoft.AspNetCore.Routing em Debug no appsettings.Development.json: cada requisição mostra se casou com algum endpoint e com qual.
Quando o proxy muda o caminho
No Nginx, o que vem depois do endereço no proxy_pass decide o que chega ao Kestrel. A documentação do módulo proxy diz que, quando o proxy_pass tem uma URI, a parte do caminho que casou com o location é trocada por ela; sem URI, o caminho segue como o cliente mandou.
# barra no fim: /painel/hubs/pedidos chega como /hubs/pedidos
location /painel/ {
proxy_pass http://127.0.0.1:5000/;
}
# sem barra: chega como /painel/hubs/pedidos (o app precisa de UsePathBase)
location /painel/ {
proxy_pass http://127.0.0.1:5000;
}
Escolha uma das duas e alinhe o aplicativo com ela. A combinação que dá 404 é a do teste 2b: o proxy repassa o prefixo e o aplicativo não tem UsePathBase. O contrário (prefixo cortado e UsePathBase ligado) não quebra o negotiate, mas o Url.Content deixa de incluir o /painel e o cliente volta a errar a URL.
O mesmo bloco precisa repassar o upgrade do WebSocket, senão o negotiate funciona e a conexão cai para Server-Sent Events ou Long Polling. A configuração está na página de proxy de WebSocket do Nginx:
location /painel/ {
proxy_pass http://127.0.0.1:5000;
proxy_http_version 1.1;
proxy_set_header Upgrade $http_upgrade;
proxy_set_header Connection "upgrade";
proxy_set_header Host $host;
}
Se o 404 vem da página padrão do Nginx, e não do Kestrel, o bloco location nem foi usado. Aí o problema é qual server atendeu a requisição, assunto do guia de nomes de servidor no Nginx. Com o aplicativo em contêiner e HTTPS no meio, vale também o passo a passo de HTTPS no ASP.NET Core dentro do Docker.
Quando há mais de uma instância
O teste 9 mostra o caso mais difícil de achar, porque funciona às vezes. A conexão foi negociada na instância B, que guardou o ID na memória. A requisição seguinte, com ?id=, caiu na instância A, que nunca viu aquele ID e respondeu 404 com No Connection with that ID. Com um balanceador alternando entre réplicas, metade das conexões quebra assim.
A página de escala do SignalR diz que o mesmo processo precisa atender todas as requisições HTTP de uma conexão e que, com mais de um servidor, é preciso sessão fixa (afinidade). Ela lista três exceções: um servidor com um processo só, o Azure SignalR Service, ou todos os clientes em WebSockets com skipNegotiation. O backplane de Redis não dispensa a afinidade; ele resolve outra coisa, que é entregar mensagens a clientes ligados em instâncias diferentes.
No Nginx, a afinidade mais simples é o ip_hash no upstream. Em Docker Swarm ou Kubernetes, o equivalente é a afinidade de sessão do balanceador ou do Ingress. Se o painel tem uma réplica só, esse caso não se aplica.
Outros códigos no mesmo lugar
- 405: no teste, veio de um GET no negotiate. A Microsoft também associa esse código a CORS não habilitado quando o front-end está em outra origem. Se for o seu caso, o texto sobre o que o CORS faz e o que ele não protege ajuda a configurar só o necessário.
- 401: o caminho existe, falta autenticação. Para enviar token ao hub, veja autenticação e autorização no SignalR, que mostra o
accessTokenFactorye a leitura do token pela query string no WebSocket. - 307: redirecionamento para HTTPS pelo
UseHttpsRedirection. O cliente deve usar a URL comhttpsdesde o começo. - Status 0 no navegador: em geral, CORS sem a origem do front-end ou sem
AllowCredentials.
Se o negotiate já volta 200 e o problema é o que fazer com a conexão depois (grupos, usuários, reconexão), o guia de notificações com SignalR continua a partir daí.
O teste completo
O aplicativo é um projeto dotnet new web com este Program.cs:
using Microsoft.AspNetCore.Authorization;
using Microsoft.AspNetCore.SignalR;
var builder = WebApplication.CreateBuilder(args);
builder.Services.AddSignalR();
builder.Services.AddAuthentication("Cookies").AddCookie("Cookies", o =>
{
// API e hub respondem 401 em vez de redirecionar para a tela de login
o.Events.OnRedirectToLogin = ctx => { ctx.Response.StatusCode = 401; return Task.CompletedTask; };
});
builder.Services.AddAuthorization();
var app = builder.Build();
// PATH_BASE=/painel simula o site publicado num subcaminho
var pathBase = Environment.GetEnvironmentVariable("PATH_BASE");
if (!string.IsNullOrEmpty(pathBase)) app.UsePathBase(pathBase);
app.UseRouting(); // depois do UsePathBase, como pede a documentação
app.UseAuthentication();
app.UseAuthorization();
app.MapGet("/onde-esta-o-hub", (HttpContext ctx) => $"{ctx.Request.PathBase}/hubs/pedidos");
app.MapHub<PedidosHub>("/hubs/pedidos");
app.MapHub<AdminHub>("/hubs/admin");
app.Run();
public class PedidosHub : Hub { }
[Authorize]
public class AdminHub : Hub { }
Duas instâncias sobem ao mesmo tempo: a A em 127.0.0.1:5101 com PATH_BASE=/painel e a B em 127.0.0.1:5102 sem prefixo. O script faz os POSTs com curl e, para o teste de instâncias, pega o connectionToken do negotiate da B e manda a mensagem de handshake para as duas. Saída:
1) caminho certo, com prefixo: 200 2) cliente sem o prefixo: 200 2b) proxy repassa /painel a um app sem UsePathBase: 404 3) singular no cliente: 404 3b) maiúsculas diferentes: 200 4) GET em vez de POST: 405 5) hub com [Authorize], sem login: 401 6) cliente do SignalR clássico: 404 7) URL que o servidor gera: /painel/hubs/pedidos 8) id criado na instância B, pedido na B: 200 9) mesmo id, pedido na instância A: 404 No Connection with that ID
Para repetir no seu ambiente, troque o 127.0.0.1 pelo endereço público e rode os mesmos POSTs passando pelo proxy. Se o resultado no proxy diverge do resultado direto no Kestrel, a diferença está na configuração do proxy.
Referência: https://stackoverflow.com/questions/19910943/signalr-negotiate-404