12k
All articles

Autenticação com WebSocket Explicada

Autenticação WebSocket explicada: token na URL, cookies, subprotocolos e primeira mensagem, além de renovação de token e autorização por mensagem.

OpenReplay Team
OpenReplay Team
Autenticação com WebSocket Explicada

A API WebSocket do navegador não suporta a definição de cabeçalhos HTTP personalizados — o construtor WebSocket aceita apenas uma URL e um array opcional de subprotocolos — portanto, a autenticação deve ocorrer por meio de um dos três mecanismos: um token na query string durante o handshake de upgrade HTTP, um cookie de sessão que o navegador envia automaticamente, ou uma credencial na primeira mensagem após a abertura da conexão. Esta é a restrição que torna a autenticação com WebSocket diferente da autenticação REST, onde bastaria anexar um cabeçalho Authorization: Bearer a cada requisição.

Se você já implementou autenticação REST com JWTs ou cookies de sessão, já conhece os componentes. O que muda com WebSockets é o mecanismo de entrega e o tempo de vida. Uma requisição REST se autentica uma vez e termina. Uma conexão WebSocket permanece aberta por minutos ou horas, o que significa que o token válido no momento do handshake pode expirar, ser revogado ou perder suas permissões enquanto o socket ainda está aberto. Este artigo cobre cada padrão — handshake versus primeira mensagem — com código funcional para navegador e Node.js, apresenta uma regra de decisão para escolher entre eles, e aborda dois aspectos que a maioria dos guias ignora: renovação de token em conexões de longa duração e autorização por mensagem.

Principais Conclusões

  • O construtor WebSocket do navegador aceita apenas uma URL e um array de subprotocolos, portanto não é possível enviar um cabeçalho Authorization — a autenticação é transferida para a query string, um cookie, o cabeçalho Sec-WebSocket-Protocol ou a primeira mensagem.
  • A autenticação com WebSocket é uma verificação contínua, não um evento único: como as conexões são de longa duração, a autorização por mensagem e a renovação de token são requisitos de produção, não medidas de segurança opcionais.
  • Use renovação de token in-band quando o socket mantém assinaturas com estado; use fechar e reconectar quando a conexão é sem estado e o contexto pode ser reconstruído com baixo custo.
  • A claim exp do JWT é um NumericDate em segundos desde a época Unix, portanto o agendamento de renovação no lado do cliente deve converter via exp * 1000 antes de comparar com Date.now().
  • Um modo de falha silenciosa comum é um socket que fecha ao expirar o token enquanto a interface continua exibindo o estado “conectado” e descarta todas as mensagens subsequentes — visível em session replay, invisível nos logs do servidor.

Por Que a Autenticação com WebSocket É Diferente

A autenticação com WebSocket é restringida no nível do navegador: a API JavaScript WebSocket não oferece nenhuma forma de anexar um cabeçalho personalizado ao handshake de abertura. Toda conexão WebSocket começa como um GET HTTP com um cabeçalho Upgrade: websocket, mas o navegador controla essa requisição inteiramente — seu código fornece apenas a URL e uma lista opcional de subprotocolos. O próprio protocolo WebSocket é explícito ao afirmar que não resolve isso por você: conforme o RFC 6455 §10.5, o protocolo “não prescreve nenhuma forma específica pela qual os servidores podem autenticar clientes durante o handshake WebSocket.”

A restrição de ausência de cabeçalho no navegador deixa quatro mecanismos práticos, todos os quais contornam essa limitação:

  1. Token via query param — coloque o token na URL de conexão.
  2. Cookie/sessão — deixe o navegador enviar um cookie de sessão existente com a requisição de upgrade.
  3. Subprotocolo Sec-WebSocket-Protocol — transporte o token no array de subprotocolos.
  4. Autenticação pela primeira mensagem — abra o socket sem autenticação e envie as credenciais como primeira mensagem.

O restante desta seção detalha cada abordagem com código para o cliente no navegador e para o servidor em Node.js, utilizando a biblioteca ws (versão estável atual: 8.x).

Token via Query Param

Coloque o token na URL de conexão e valide-o durante o upgrade HTTP, antes de alocar recursos de conexão. Este é o método mais simples e o mais amplamente utilizado; sua vantagem é a rejeição rápida — o servidor pode retornar um 401 antes mesmo de o socket ser estabelecido.

// Cliente no navegador
const token = localStorage.getItem('authToken');
const socket = new WebSocket(`wss://api.example.com/ws?token=${encodeURIComponent(token)}`);
// Servidor Node.js — ws@8.21.0
import { WebSocketServer } from 'ws';
import { verify } from 'jsonwebtoken';

const wss = new WebSocketServer({ noServer: true });

server.on('upgrade', (req, socket, head) => {
  const { searchParams } = new URL(req.url, 'wss://api.example.com');
  const token = searchParams.get('token');
  try {
    const user = verify(token, process.env.JWT_SECRET);
    wss.handleUpgrade(req, socket, head, (ws) => {
      ws.user = user;
      wss.emit('connection', ws, req);
    });
  } catch {
    socket.write('HTTP/1.1 401 Unauthorized\r\n\r\n');
    socket.destroy();
  }
});

Risco concreto: um token na URL do WebSocket é registrado por padrão nos logs de acesso do nginx e do Apache — ambos os formatos de log padrão (combined / %r) incluem a linha de requisição completa com sua query string — pode aparecer no histórico do navegador e pode vazar pelo cabeçalho Referer se a página navegar após a conexão. Tokens de curta duração (5–15 minutos é uma convenção do setor, não uma diretriz documentada pela OWASP) reduzem, mas não eliminam, essa exposição.

A autenticação baseada em cookie reutiliza uma sessão já estabelecida via HTTP: o navegador anexa automaticamente cookies do mesmo domínio à requisição de upgrade, portanto nenhum código de autenticação no lado do cliente é necessário. Este é o método de menor atrito quando seu endpoint WebSocket compartilha um domínio com a aplicação que realizou o login do usuário.

// Cliente no navegador — nenhum tratamento de token necessário; o cookie é enviado automaticamente
const socket = new WebSocket('wss://app.example.com/ws');
// Servidor Node.js — ws@8.21.0
import { parse } from 'cookie';

server.on('upgrade', (req, socket, head) => {
  const origin = req.headers.origin;
  if (origin !== 'https://app.example.com') {
    socket.write('HTTP/1.1 403 Forbidden\r\n\r\n');
    return socket.destroy();
  }
  const cookies = parse(req.headers.cookie || '');
  const session = sessionStore.get(cookies.sid);
  if (!session) {
    socket.write('HTTP/1.1 401 Unauthorized\r\n\r\n');
    return socket.destroy();
  }
  wss.handleUpgrade(req, socket, head, (ws) => {
    ws.user = session.user;
    wss.emit('connection', ws, req);
  });
});

Risco concreto: a autenticação WebSocket baseada em cookie exige tanto a validação do cabeçalho Origin no servidor quanto o atributo SameSite=Strict ou SameSite=Lax no cookie. Conforme descrito na documentação do SameSite no MDN, SameSite controla se um cookie é enviado em requisições entre sites; SameSite=None (necessário para cookies genuinamente entre domínios diferentes) reexpõe a superfície de ataque CSRF que o SameSite foi introduzido para fechar. Cookies também falham entre domínios diferentes, que é o principal motivo pelo qual equipes recorrem aos dois métodos seguintes.

Subprotocolo Sec-WebSocket-Protocol

É possível passar um token como valor de subprotocolo, pois o array de subprotocolos é a única parte do handshake que o navegador expõe. O cabeçalho Sec-WebSocket-Protocol foi projetado para negociar subprotocolos no nível da aplicação, não para transportar tokens de autenticação; utilizá-lo para autenticação é um contorno que funciona em todos os principais navegadores, mas deve ser tratado como último recurso.

Os valores de subprotocolo devem ser valores token válidos sem caracteres separadores, portanto o token deve ser codificado em base64url sem padding — os caracteres / e = do base64 padrão não são válidos aqui. O alfabeto base64url é definido no RFC 4648 §5.

// Cliente no navegador — token como valor de subprotocolo base64url sem padding
const token = localStorage.getItem('authToken'); // já em base64url, sem padding
const socket = new WebSocket('wss://api.example.com/ws', ['auth.bearer', token]);
// Servidor Node.js — ws@8.21.0
const wss = new WebSocketServer({
  noServer: true,
  handleProtocols: (protocols) => {
    // protocols é um Set; a segunda entrada é nosso token
    const [, token] = [...protocols];
    return validate(token) ? 'auth.bearer' : false;
  },
});

Risco concreto: assim como o método via query param, o valor do subprotocolo pode aparecer em logs que registram cabeçalhos do handshake, e o requisito de codificação (base64url sem padding) é fácil de implementar incorretamente. Reserve este método para casos em que cookies estão bloqueados entre domínios e a exposição via query param é inaceitável.

Autenticação pela Primeira Mensagem

A conexão é aberta sem autenticação e o cliente envia as credenciais como sua primeira mensagem; o servidor valida antes de processar qualquer outra coisa. Isso mantém os tokens fora de URLs e logs, ao custo de um protocolo de enfileiramento e timeout que você precisa construir por conta própria.

// Cliente no navegador — enfileira mensagens da aplicação até que AUTH seja bem-sucedido
const socket = new WebSocket('wss://api.example.com/ws');
const queue = [];
let authed = false;

socket.onopen = () => {
  socket.send(JSON.stringify({ type: 'AUTH', token: localStorage.getItem('authToken') }));
};

socket.onmessage = (e) => {
  const msg = JSON.parse(e.data);
  if (msg.type === 'AUTH_OK') {
    authed = true;
    queue.forEach((m) => socket.send(m));
    queue.length = 0;
  }
};

function send(data) {
  const m = JSON.stringify(data);
  authed ? socket.send(m) : queue.push(m);
}
// Servidor Node.js — ws@8.21.0
wss.on('connection', (ws) => {
  const timer = setTimeout(() => ws.close(4001, 'auth timeout'), 7000);
  ws.once('message', (raw) => {
    const msg = JSON.parse(raw);
    if (msg.type !== 'AUTH') return ws.close(4002, 'expected AUTH');
    try {
      ws.user = verify(msg.token, process.env.JWT_SECRET);
      clearTimeout(timer);
      ws.send(JSON.stringify({ type: 'AUTH_OK' }));
    } catch {
      ws.close(4003, 'invalid token');
    }
  });
});

Risco concreto: um servidor que aceita conexões WebSocket não autenticadas deve impor um timeout rígido de autenticação — tipicamente de 5 a 10 segundos, uma convenção sem base em especificação — e fechar qualquer conexão que não envie credenciais válidas dentro desse intervalo. Sem isso, um atacante pode esgotar os limites de conexão com sockets abertos mas não autenticados. O valor correto depende do seu tempo de resposta no percentil 95 (p95): 5 segundos é adequado para conexões de baixa latência, enquanto 10 segundos é mais seguro para clientes móveis e de alta latência.

Comparação de Métodos e Regra de Decisão

Escolha o método com base nas restrições do seu ambiente de implantação, não pelo que é “mais seguro” em abstrato — todos os quatro são seguros quando implementados corretamente. A tabela abaixo resume as trocas envolvidas; a regra de decisão que se segue resolve os casos mais comuns.

MétodoExposição do tokenRisco de CSRFEntre domíniosComplexidadeUse quando
Token via query paramLogs, histórico, RefererNenhumSimBaixaVocê controla os logs do servidor e quer a implementação mais simples
Cookie / sessãoNenhuma (cookie é HttpOnly)Sim (mitigue com SameSite + Origin)NãoBaixaMesmo domínio, sessão existente
Cabeçalho de subprotocoloLogs do handshakeNenhumSimMédiaCookies bloqueados, query params inaceitáveis
Primeira mensagemNenhumaNenhumSimAltaTokens não podem aparecer em URLs de forma alguma

A regra de decisão: use tokens via query param quando você controla os logs do servidor e precisa da implementação mais simples; use cookies quando estiver no mesmo domínio e já tiver uma sessão; use autenticação pela primeira mensagem quando precisar manter os tokens completamente fora das URLs e puder absorver a complexidade do enfileiramento; use o cabeçalho de subprotocolo apenas quando cookies estiverem bloqueados entre domínios e a exposição via query param for inaceitável — é uma abordagem não padronizada e deve ser o último recurso. Equipes que preferem não gerenciar nada disso frequentemente optam por uma plataforma de tempo real gerenciada, que trata o ciclo de vida do token como parte do serviço.

Renovação de Token em Conexões de Longa Duração

Uma conexão WebSocket sobrevive ao seu token, portanto é necessário definir uma estratégia de renovação desde o início. Existem duas opções: renovar o token in-band pela conexão aberta, ou fechar e reconectar com um novo token. Use renovação de token in-band — enviando um novo token pela conexão existente — quando o socket mantém assinaturas com estado ou operações pendentes que seriam perdidas ao reconectar; use fechar e reconectar quando a conexão é sem estado e o cliente pode reconstruir o contexto com baixo custo sem perda de dados.

O cliente agenda a renovação a partir da própria expiração do JWT. A claim exp é um NumericDate em segundos desde a época Unix, conforme o RFC 7519 §4.1.4, portanto é necessário multiplicar por 1000 antes de comparar com Date.now().

// Navegador — agenda renovação in-band 60s antes da expiração, com fallback para reconexão
class WebSocketAuthManager {
  constructor(url) {
    this.url = url;
    this.connect();
  }

  connect() {
    this.ws = new WebSocket(`${this.url}?token=${getToken()}`);
    this.ws.onopen = () => this.scheduleRefresh();
    this.ws.onmessage = (e) => {
      const msg = JSON.parse(e.data);
      if (msg.type === 'TOKEN_REFRESH_OK') this.scheduleRefresh();
    };
  }

  scheduleRefresh() {
    const { exp } = parseJwt(getToken());        // exp está em segundos
    const fireAt = exp * 1000 - Date.now() - 60_000; // 60s antes da expiração
    clearTimeout(this.timer);
    this.timer = setTimeout(() => this.refresh(), Math.max(0, fireAt));
  }

  async refresh() {
    try {
      const fresh = await fetchNewToken();
      setToken(fresh);
      this.ws.send(JSON.stringify({ type: 'TOKEN_REFRESH', token: fresh }));
    } catch {
      this.ws.close(4004, 'refresh failed'); // fallback para uma reconexão limpa
      this.connect();
    }
  }
}

O servidor valida o token renovado e atualiza a sessão associada ao socket — sem encerrar a conexão, que é exatamente o propósito do caminho in-band:

// Servidor Node.js — ws@8.21.0
ws.on('message', (raw) => {
  const msg = JSON.parse(raw);
  if (msg.type === 'TOKEN_REFRESH') {
    try {
      ws.user = verify(msg.token, process.env.JWT_SECRET);
      ws.send(JSON.stringify({ type: 'TOKEN_REFRESH_OK' }));
    } catch {
      ws.close(4003, 'invalid refresh token');
    }
  }
});

A Autorização É uma Verificação Contínua

A autenticação com WebSocket não é um evento único no momento da abertura da conexão: como as conexões são de longa duração, um token válido no handshake pode expirar, ser revogado ou perder as permissões associadas enquanto o socket ainda está aberto, tornando a autorização por mensagem um requisito de produção, não uma medida de segurança opcional. As permissões mudam durante uma conexão por ações administrativas, expiração de assinaturas ou moderação — e o handshake realizado há uma hora não pode levar nada disso em conta.

A solução é verificar a autorização em cada mensagem recebida, não apenas no momento da conexão. Trate o handshake como o estabelecimento de identidade e cada mensagem como uma decisão de autorização independente:

// Servidor Node.js — verificação de permissão por mensagem
const PERMISSIONS = { admin: ['read', 'write', 'delete'], user: ['read', 'write'], guest: ['read'] };

ws.on('message', (raw) => {
  const msg = JSON.parse(raw);
  const allowed = PERMISSIONS[ws.user.role] || [];
  if (!allowed.includes(msg.action)) {
    return ws.send(JSON.stringify({ type: 'FORBIDDEN', action: msg.action }));
  }
  handle(msg, ws);
});

Combine isso com uma verificação de revogação para que um usuário desconectado ou banido não possa continuar operando em um socket já aberto. Um Set em memória com IDs de tokens revogados funciona para um único processo; um armazenamento compartilhado como o Redis é necessário quando se executam múltiplos nós WebSocket, pois a revogação precisa ser visível para qualquer nó que mantenha a conexão.

Checklist de Segurança e Depuração de Falhas Silenciosas

Proteger uma conexão WebSocket resume-se a uma lista curta e concreta de verificações. Percorra-a antes de publicar em produção:

  • Use apenas wss://. O RFC 6455 §10.6 recomenda executar WebSockets sobre TLS para garantir confidencialidade e integridade. O ws:// simples expõe tokens e mensagens em trânsito.
  • Mantenha os tokens de curta duração. O Session Management Cheat Sheet da OWASP recomenda timeouts de inatividade de 2–5 minutos para aplicações de alto valor e 15–30 minutos para as de menor risco; opte pelo lado mais curto para tokens armazenados no navegador.
  • Imponha um timeout de autenticação na autenticação pela primeira mensagem (5–10 segundos, por convenção) e feche sockets não autenticados.
  • Limite a taxa de conexões por IP ou por usuário para conter a rotatividade de sockets não autenticados. Qualquer número específico (como um limite máximo de conexões simultâneas) é ilustrativo, não um padrão — defina-o com base no seu próprio baseline de tráfego.
  • Suporte à revogação. Verifique uma lista de revogação na conexão e em cada mensagem para que uma sessão encerrada não persista em um socket aberto.
  • Valide o Origin para autenticação baseada em cookie e defina SameSite=Strict ou Lax.

Esses itens importam porque a autenticação com WebSocket falha silenciosamente. Quando um token de curta duração expira durante uma sessão e o servidor fecha o socket, o navegador dispara um evento close; se a aplicação não tratar esse evento, a interface pode continuar exibindo o estado “conectado” enquanto todas as mensagens subsequentes são descartadas silenciosamente — uma falha invisível nos logs do servidor, mas visível em session replay, onde se observa a última mensagem bem-sucedida seguida de silêncio no lado do cliente. O mesmo vale para o padrão de primeira mensagem: em uma conexão lenta, mensagens enfileiradas antes da conclusão da autenticação podem ser descartadas se o timeout de autenticação disparar primeiro, e o session replay dessas sessões mostra o cliente enviando mensagens que nunca geram resposta enquanto o socket ainda parece aberto. Reproduzir o lado do cliente frequentemente é a única forma de explicar uma classe de bug que os logs do servidor registram apenas como um frame de fechamento sem contexto.

Conclusão

Trate a ausência do cabeçalho Authorization como o ponto de partida, não como o problema completo: escolha um método de entrega com base na regra de decisão acima, defina sua estratégia de renovação e implemente a autorização por mensagem antes que a conexão seja de longa duração em produção. O handshake prova quem se conectou; tudo o que vem depois é onde a segurança real do WebSocket reside. Comece auditando um socket existente — confirme que ele opera sobre wss://, rejeita tokens expirados durante a sessão e informa ao usuário quando a conexão é encerrada, em vez de descartar mensagens silenciosamente.

Perguntas Frequentes

Posso enviar um cabeçalho Authorization Bearer em uma conexão WebSocket pelo navegador?

Não. O construtor WebSocket do navegador aceita apenas uma URL e um array opcional de subprotocolos, portanto não há API para anexar um cabeçalho Authorization personalizado ao handshake de abertura. As quatro alternativas práticas são: um token na query string, um cookie de sessão enviado automaticamente pelo navegador, uma credencial codificada no valor de subprotocolo Sec-WebSocket-Protocol, ou uma credencial enviada como primeira mensagem após a abertura da conexão. Bibliotecas WebSocket nativas fora do navegador, como clientes Node.js, podem definir cabeçalhos arbitrários, mas o código do navegador não pode.

O que acontece com mensagens enviadas antes da conclusão da autenticação pela primeira mensagem?

Mensagens enviadas antes da conclusão do handshake AUTH devem ser enfileiradas no cliente e enviadas apenas após o servidor confirmar a autenticação, pois qualquer coisa enviada antes pode ser descartada silenciosamente. Se o timeout de autenticação do servidor disparar antes de receber credenciais válidas — tipicamente dentro de uma janela de 5 a 10 segundos — a conexão é fechada e quaisquer mensagens em trânsito ou enfileiradas são perdidas. Em conexões lentas, isso produz uma falha em que o socket ainda parece aberto enquanto as mensagens nunca recebem resposta. Sempre condicione os envios da aplicação a um sinalizador de autenticado.

Devo renovar um token WebSocket in-band ou fechar e reconectar?

Use renovação in-band — enviando um novo token pela conexão existente — quando a conexão mantém assinaturas com estado ou operações pendentes que seriam perdidas ao reconectar. Use fechar e reconectar quando a conexão é sem estado e o cliente pode reconstruir o contexto com baixo custo sem perda de dados. A escolha é arquitetural, não estilística: reconectar desfaz o estado de assinatura no servidor, portanto, para um feed ao vivo com muitos canais ativos, a renovação in-band evita a sobrecarga de reassinatura e eventos perdidos durante o intervalo.

Por que minha interface WebSocket continua exibindo 'conectado' após a expiração do token?

O servidor fecha o socket quando o token expira e o navegador dispara um evento close, mas se o gerenciamento de estado da aplicação não tratar esse evento, a interface continua exibindo o estado conectado enquanto todas as mensagens subsequentes são descartadas silenciosamente. Essa falha é invisível nos logs do servidor, que registram apenas um frame de fechamento sem contexto, mas visível em session replay como a última mensagem bem-sucedida seguida de silêncio no lado do cliente. Trate o evento close explicitamente para informar ao usuário sobre a desconexão e acionar a reconexão.

Understand every bug

Uncover frustrations, understand bugs and fix slowdowns like never before with OpenReplay — self-hosted, with full data ownership.

Star on GitHub

We use cookies to improve your experience. By using our site, you accept cookies.