WebSocket com autenticação JWT em Node.js

WebSockets são conexões persistentes bidirecionais, o servidor pode enviar mensagens ao cliente a qualquer momento. Autenticar essas conexões é diferente de requisições HTTP: o token precisa ser validado no handshake inicial, e a conexão deve ser encerrada se o token expirar durante a sessão.

WebSocket autenticado com biblioteca ws

Validar JWT no handshake HTTP antes de fazer o upgrade para WebSocket:

typescript
// npm install ws @types/ws

import { WebSocketServer, WebSocket } from 'ws'
import { IncomingMessage } from 'http'
import { verificarAccessToken, type TokenPayload } from './lib/jwt'

// Estender WebSocket para guardar dados do usuário:
interface WebSocketAutenticado extends WebSocket {
  usuario?: TokenPayload
  isAlive?: boolean
}

const wss = new WebSocketServer({ port: 8080 })

// Autenticar no handshake:
wss.on('connection', async (ws: WebSocketAutenticado, req: IncomingMessage) => {
  // Token pode vir como query param (impossível usar header em WebSocket browser nativo):
  // ws://api.seudominio.com.br/ws?token=eyJ...
  const url = new URL(req.url!, `http://${req.headers.host}`)
  const token = url.searchParams.get('token')

  if (!token) {
    ws.close(4001, 'Token não fornecido')
    return
  }

  try {
    ws.usuario = await verificarAccessToken(token)
  } catch {
    ws.close(4001, 'Token inválido ou expirado')
    return
  }

  console.log(`WebSocket conectado: ${ws.usuario.email}`)

  ws.isAlive = true
  ws.on('pong', () => { ws.isAlive = true })

  ws.on('message', (data) => {
    const msg = JSON.parse(data.toString())
    handleMensagem(ws, msg)
  })

  ws.on('close', () => {
    console.log(`WebSocket desconectado: ${ws.usuario?.email}`)
  })

  // Confirmar conexão:
  ws.send(JSON.stringify({ type: 'connected', userId: ws.usuario.userId }))
})

// Heartbeat para detectar conexões mortas:
const heartbeat = setInterval(() => {
  wss.clients.forEach((ws: WebSocketAutenticado) => {
    if (!ws.isAlive) return ws.terminate()
    ws.isAlive = false
    ws.ping()
  })
}, 30_000)

wss.on('close', () => clearInterval(heartbeat))

Socket.IO com autenticação JWT

Middleware de autenticação e rooms por usuário no Socket.IO:

typescript
// npm install socket.io

import { Server } from 'socket.io'
import { verificarAccessToken } from './lib/jwt'

const io = new Server(httpServer, {
  cors: {
    origin: ['https://seudominio.com.br'],
    credentials: true,
  },
})

// Middleware de autenticação (executa antes de cada conexão):
io.use(async (socket, next) => {
  const token =
    socket.handshake.auth.token ||          // enviado pelo cliente via auth option
    socket.handshake.headers.authorization?.slice(7)

  if (!token) return next(new Error('Token não fornecido'))

  try {
    socket.data.usuario = await verificarAccessToken(token)
    next()
  } catch {
    next(new Error('Token inválido'))
  }
})

io.on('connection', (socket) => {
  const usuario = socket.data.usuario

  // Entrar em rooms pessoais (receber notificações diretas):
  socket.join(`user:${usuario.userId}`)

  // Entrar em rooms de organização:
  socket.join(`org:${usuario.orgId}`)

  console.log(`Socket conectado: ${usuario.email} [${socket.id}]`)

  // Escutar eventos do cliente:
  socket.on('mensagem:enviar', async (data) => {
    // Emitir para todos na room:
    io.to(`org:${usuario.orgId}`).emit('mensagem:nova', {
      de: usuario.email,
      conteudo: data.conteudo,
      timestamp: Date.now(),
    })
  })

  socket.on('disconnect', () => {
    console.log(`Socket desconectado: ${usuario.email}`)
  })
})

// Enviar notificação para usuário específico do servidor:
export function notificarUsuario(userId: string, evento: string, dados: unknown) {
  io.to(`user:${userId}`).emit(evento, dados)
}

Cliente frontend com reconnect e refresh de token

Reconectar automaticamente com token renovado ao receber erro 4001:

typescript
// frontend/websocket.ts
import { refreshAccessToken } from './auth'

let ws: WebSocket | null = null
let token = ''
let reconnectTimer: ReturnType<typeof setTimeout> | null = null
let reconnectDelay = 1000

export async function conectarWebSocket() {
  token = getAccessTokenFromMemoria()

  ws = new WebSocket(`wss://api.seudominio.com.br/ws?token=${token}`)

  ws.addEventListener('open', () => {
    console.log('WebSocket conectado')
    reconnectDelay = 1000  // resetar delay ao conectar com sucesso
  })

  ws.addEventListener('message', (event) => {
    const msg = JSON.parse(event.data)
    handleMensagem(msg)
  })

  ws.addEventListener('close', async (event) => {
    console.log(`WebSocket fechado: ${event.code} ${event.reason}`)

    if (event.code === 4001) {
      // Token expirado — tentar renovar:
      try {
        token = await refreshAccessToken()
        salvarAccessTokenNaMemoria(token)
        conectarWebSocket()  // reconectar imediatamente com novo token
        return
      } catch {
        // Refresh falhou — redirecionar para login:
        window.location.href = '/login'
        return
      }
    }

    // Reconectar com backoff exponencial:
    if (reconnectTimer) clearTimeout(reconnectTimer)
    reconnectTimer = setTimeout(() => {
      reconnectDelay = Math.min(reconnectDelay * 2, 30_000)
      conectarWebSocket()
    }, reconnectDelay)
  })

  ws.addEventListener('error', (error) => {
    console.error('WebSocket erro:', error)
  })

  return ws
}

export function enviarMensagem(tipo: string, dados: unknown) {
  if (ws?.readyState === WebSocket.OPEN) {
    ws.send(JSON.stringify({ type: tipo, ...dados as object }))
  }
}

Broadcast e mensagens direcionadas

Padrões para enviar mensagens para usuários, grupos e todos:

typescript
// Padrões de envio no Socket.IO:

// 1. Para um usuário específico (de qualquer parte do código):
io.to(`user:${userId}`).emit('notificacao', { mensagem: 'Novo pedido criado!' })

// 2. Para todos em uma organização:
io.to(`org:${orgId}`).emit('pedido:atualizado', { pedidoId, status: 'aprovado' })

// 3. Broadcast para todos (exceto o remetente):
socket.broadcast.emit('usuario:online', { nome: usuario.nome })

// 4. Para todos (incluindo o remetente):
io.emit('manutencao:aviso', { mensagem: 'Sistema em manutenção em 10 min' })

// 5. Para uma sala específica (ex: chat de suporte):
io.to(`ticket:${ticketId}`).emit('mensagem:nova', dados)

// Usando com Express — enviar notificação em rota HTTP:
app.post('/api/pedidos', autenticar, async (req, res) => {
  const pedido = await criarPedido(req.body)

  // Notificar o criador do pedido em tempo real:
  notificarUsuario(req.usuario!.userId, 'pedido:criado', { pedidoId: pedido.id })

  // Notificar administradores:
  io.to('role:admin').emit('pedido:novo', { pedido })

  res.status(201).json(pedido)
})

WebSocket atrás de Nginx

Configurar proxy WebSocket no Nginx:

bash
# /etc/nginx/sites-enabled/api.conf

server {
    listen 443 ssl;
    server_name api.seudominio.com.br;

    # Proxy para API HTTP:
    location /api/ {
        proxy_pass http://127.0.0.1:3000;
        proxy_http_version 1.1;
        proxy_set_header Host $host;
        proxy_set_header X-Real-IP $remote_addr;
    }

    # Proxy WebSocket (upgrade necessário):
    location /ws {
        proxy_pass http://127.0.0.1:8080;
        proxy_http_version 1.1;

        # Headers obrigatórios para WebSocket:
        proxy_set_header Upgrade $http_upgrade;
        proxy_set_header Connection "upgrade";
        proxy_set_header Host $host;
        proxy_set_header X-Real-IP $remote_addr;

        # Timeout longo para conexões persistentes:
        proxy_read_timeout 3600s;   # 1 hora
        proxy_send_timeout 3600s;

        # Desabilitar buffering para WebSocket:
        proxy_buffering off;
    }

    # Socket.IO usa /socket.io/ por padrão:
    location /socket.io/ {
        proxy_pass http://127.0.0.1:3000/socket.io/;
        proxy_http_version 1.1;
        proxy_set_header Upgrade $http_upgrade;
        proxy_set_header Connection "upgrade";
        proxy_read_timeout 3600s;
        proxy_buffering off;
    }
}

Não quer configurar manualmente?

Implante o VPS para APIs em menos de 5 minutos com a Runstack. Infraestrutura da OPEN Datacenter, com servidores em São Paulo.

Perguntas frequentes

WebSocket nativo (biblioteca ws): menor overhead, sem dependências extras, bom para casos simples ou quando o cliente não é browser (app mobile, IoT). Socket.IO: rooms built-in, reconexão automática, fallback para long-polling em ambientes sem WebSocket, namespace, eventos nomeados com acknowledgment. Para sistemas de notificação em tempo real com múltiplos rooms e reconexão: Socket.IO. Para protocolos personalizados de baixa latência: ws nativo.

O WebSocket nativo do browser não suporta headers customizados no handshake, apenas cookies (enviados automaticamente) e query params. Opções: (1) token como query param: ?token=eyJ... (simples, mas token visível em logs de acesso do servidor); (2) cookie httpOnly (mais seguro, enviado automaticamente, mas requer CORS configurado); (3) Socket.IO suporta io.handshake.auth.token, enviado via socket.auth no cliente. A opção com Socket.IO auth é a mais elegante.

O JWT foi validado apenas no handshake inicial, durante a conexão, o servidor não revalida automaticamente. Opções: (1) aceitar o risco (janela de 15min, aceitável para muitos casos); (2) o cliente envia um novo token periodicamente via mensagem WebSocket antes do access token expirar; (3) o servidor rastreia quando o token vai expirar e fecha a conexão proativamente; (4) usar refresh token via HTTP paralelo e enviar novo access token via WebSocket ao cliente.

WebSocket cria estado de conexão por processo, dois clientes em instâncias diferentes não conseguem se comunicar diretamente. Solução: Redis Pub/Sub como backplane. Socket.IO tem o pacote @socket.io/redis-adapter que redistribui eventos entre instâncias automaticamente. Configure: io.adapter(createAdapter(pubClient, subClient)). Com isso, io.to(room).emit() funciona mesmo com instâncias diferentes no mesmo load balancer.

SSE: unidirecional (servidor → cliente apenas), funciona sobre HTTP/1.1, mais simples de implementar, reconexão automática built-in no browser, funciona com proxies sem configuração especial. WebSocket: bidirecional, maior complexidade, requer upgrade de protocolo. Para notificações push simples (dashboards, feeds de atividade): SSE é mais simples e suficiente. Para chat, jogos, colaboração em tempo real onde o cliente também envia dados: WebSocket.