12k
All articles

Autenticación con WebSocket: Guía Completa

Métodos de autenticación WebSocket: tokens en la URL, cookies, subprotocolos y primer mensaje, además de renovación de token y autorización por mensaje.

OpenReplay Team
OpenReplay Team
Autenticación con WebSocket: Guía Completa

La API de WebSocket del navegador no permite establecer cabeceras HTTP personalizadas — el constructor WebSocket solo acepta una URL y un array opcional de subprotocolos — por lo que la autenticación debe realizarse mediante uno de tres mecanismos: un token en la cadena de consulta durante el handshake de actualización HTTP, una cookie de sesión que el navegador envía automáticamente, o una credencial en el primer mensaje tras abrir la conexión. Esta es la restricción que diferencia la autenticación con WebSocket de la autenticación REST, donde simplemente se adjunta una cabecera Authorization: Bearer a cada solicitud.

Si ya has implementado autenticación REST con JWTs o cookies de sesión, conoces los componentes. Lo que cambia con WebSocket es el mecanismo de entrega y el ciclo de vida. Una solicitud REST se autentica una vez y finaliza. Una conexión WebSocket permanece abierta durante minutos u horas, lo que significa que el token válido en el momento del handshake puede expirar, ser revocado o perder sus permisos mientras el socket sigue abierto. Este artículo cubre cada patrón — handshake vs. primer mensaje — con código funcional para el navegador y Node.js, ofrece una regla de decisión para elegir entre ellos, y aborda dos aspectos que la mayoría de las guías omiten: la renovación de tokens en conexiones de larga duración y la autorización por mensaje.

Puntos Clave

  • El constructor WebSocket del navegador solo acepta una URL y un array de subprotocolos, por lo que no es posible enviar una cabecera Authorization — la autenticación se traslada a la cadena de consulta, una cookie, la cabecera Sec-WebSocket-Protocol o el primer mensaje.
  • La autenticación con WebSocket es una verificación continua, no un evento puntual: dado que las conexiones son de larga duración, la autorización por mensaje y la renovación de tokens son requisitos de producción, no medidas de seguridad opcionales.
  • Utiliza la renovación de tokens en banda cuando el socket gestiona suscripciones con estado; usa cierre y reconexión cuando la conexión es sin estado y el contexto es fácil de reconstruir.
  • El claim exp de JWT es un NumericDate en segundos desde la época Unix, por lo que la programación de la renovación en el cliente debe convertir mediante exp * 1000 antes de comparar con Date.now().
  • Un modo de fallo silencioso habitual es un socket que se cierra al expirar el token mientras la interfaz sigue mostrando el estado “conectado” y descarta todos los mensajes posteriores — visible en la reproducción de sesiones, invisible en los registros del servidor.

Por Qué la Autenticación con WebSocket Es Diferente

La autenticación con WebSocket está condicionada a nivel del navegador: la API JavaScript de WebSocket no ofrece ninguna forma de adjuntar una cabecera personalizada al handshake de apertura. Toda conexión WebSocket comienza como una solicitud HTTP GET con una cabecera Upgrade: websocket, pero el navegador controla esa solicitud por completo — tu código solo proporciona la URL y una lista opcional de subprotocolos. El propio protocolo WebSocket es explícito al respecto: según el RFC 6455 §10.5, el protocolo “no prescribe ninguna forma particular en que los servidores puedan autenticar a los clientes durante el handshake de WebSocket.”

La restricción de cabeceras del navegador deja cuatro mecanismos prácticos, todos los cuales la sortean:

  1. Token en parámetro de consulta — incluir el token en la URL de conexión.
  2. Cookie/sesión — dejar que el navegador envíe una cookie de sesión existente con la solicitud de actualización.
  3. Subprotocolo Sec-WebSocket-Protocol — transportar el token en el array de subprotocolos.
  4. Autenticación en el primer mensaje — abrir el socket sin autenticar y enviar las credenciales como primer mensaje.

El resto de esta sección analiza cada opción con código del cliente en el navegador y código del servidor en Node.js usando la librería ws (versión estable actual: 8.x).

Token en Parámetro de Consulta

Incluye el token en la URL de conexión y valídalo durante la actualización HTTP, antes de asignar recursos a la conexión. Es el método más sencillo y el más utilizado, y su ventaja es el rechazo rápido — el servidor puede devolver un 401 antes de que el socket llegue a establecerse.

// Cliente en el 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();
  }
});

Riesgo concreto: un token en la URL del WebSocket queda registrado por defecto en los logs de acceso de nginx y Apache — ambos formatos de log predeterminados (combined / %r) incluyen la línea de solicitud completa con su cadena de consulta — puede aparecer en el historial del navegador y filtrarse a través de la cabecera Referer si la página navega tras conectarse. Los tokens de corta duración (5–15 minutos es una convención del sector, no una cifra documentada por OWASP) reducen pero no eliminan esta exposición.

La autenticación basada en cookies reutiliza una sesión ya establecida sobre HTTP: el navegador adjunta automáticamente las cookies del mismo dominio a la solicitud de actualización, por lo que no se necesita ningún código de autenticación en el cliente. Es el método con menor fricción cuando el endpoint WebSocket comparte dominio con la aplicación que autenticó al usuario.

// Cliente en el navegador — no se necesita gestión de tokens; la cookie viaja automáticamente
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);
  });
});

Riesgo concreto: la autenticación WebSocket basada en cookies requiere tanto la validación de la cabecera Origin en el servidor como el atributo SameSite=Strict o SameSite=Lax en la cookie. Como describe la documentación de SameSite en MDN, SameSite controla si una cookie se envía en solicitudes entre sitios; SameSite=None (necesario para cookies genuinamente entre dominios) vuelve a exponer la superficie CSRF que SameSite fue diseñado para cerrar. Las cookies también fallan entre dominios distintos, que es la razón principal por la que los equipos recurren a los dos métodos siguientes.

Subprotocolo Sec-WebSocket-Protocol

Es posible pasar un token como valor de subprotocolo, ya que el array de subprotocolos es la única parte del handshake que el navegador sí expone. La cabecera Sec-WebSocket-Protocol fue diseñada para negociar subprotocolos a nivel de aplicación, no para transportar tokens de autenticación; utilizarla con este fin es un recurso alternativo que funciona en todos los navegadores principales, pero debe considerarse un último recurso.

Los valores de subprotocolo deben ser valores token válidos sin caracteres separadores, por lo que el token debe codificarse como base64url sin relleno — los caracteres / y = del base64 estándar no son válidos aquí. El alfabeto base64url está definido en el RFC 4648 §5.

// Cliente en el navegador — token como valor de subprotocolo en base64url sin relleno
const token = localStorage.getItem('authToken'); // ya en base64url, sin relleno
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 es un Set; la segunda entrada es nuestro token
    const [, token] = [...protocols];
    return validate(token) ? 'auth.bearer' : false;
  },
});

Riesgo concreto: al igual que con el método de parámetro de consulta, el valor del subprotocolo puede quedar registrado en logs que almacenan cabeceras del handshake, y el requisito de codificación (base64url sin relleno) es fácil de implementar incorrectamente. Reserva este método para casos en los que las cookies estén bloqueadas entre dominios y la exposición mediante parámetros de consulta sea inaceptable.

Autenticación en el Primer Mensaje

La conexión se abre sin autenticar y el cliente envía las credenciales como primer mensaje; el servidor las valida antes de procesar cualquier otra cosa. Esto mantiene los tokens fuera de las URLs y los logs por completo, a costa de un protocolo de cola y tiempo de espera que debes implementar tú mismo.

// Cliente en el navegador — encolar mensajes de la aplicación hasta que AUTH tenga éxito
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');
    }
  });
});

Riesgo concreto: un servidor que acepta conexiones WebSocket sin autenticar debe imponer un tiempo límite de autenticación estricto — típicamente entre 5 y 10 segundos, una convención sin base en ninguna especificación — y cerrar cualquier conexión que no envíe credenciales válidas dentro de ese período. Sin esto, un atacante puede agotar los límites de conexión con sockets abiertos pero sin autenticar. El valor adecuado depende del tiempo de ida y vuelta en el percentil 95: 5 segundos es suficiente para conexiones de baja latencia, 10 segundos es más seguro para clientes móviles y de alta latencia.

Comparación de Métodos y Regla de Decisión

Elige el método según las restricciones de tu entorno de despliegue, no por cuál es “más seguro” en abstracto — los cuatro son seguros cuando se implementan correctamente. La tabla siguiente resume las ventajas e inconvenientes; la regla de decisión que sigue resuelve los casos más comunes.

MétodoExposición del tokenRiesgo CSRFEntre dominiosComplejidadCuándo usarlo
Token en parámetro de consultaLogs, historial, RefererNingunoBajaCuando controlas los logs del servidor y quieres la implementación más sencilla
Cookie / sesiónNinguna (cookie es HttpOnly)Sí (mitigar con SameSite + Origin)NoBajaMismo dominio, sesión existente
Cabecera de subprotocoloLogs del handshakeNingunoMediaCookies bloqueadas, parámetros de consulta inaceptables
Primer mensajeNingunaNingunoAltaLos tokens deben mantenerse fuera de las URLs por completo

La regla de decisión: usa tokens en parámetro de consulta cuando controlas los logs del servidor y necesitas la implementación más sencilla; usa cookies cuando estás en el mismo dominio y ya tienes una sesión; usa autenticación en el primer mensaje cuando necesitas mantener los tokens fuera de las URLs por completo y puedes asumir la complejidad de la cola; usa la cabecera de subprotocolo solo cuando las cookies estén bloqueadas entre dominios y la exposición mediante parámetros de consulta sea inaceptable — es un enfoque no estándar y debe ser el último recurso. Los equipos que prefieren no gestionar nada de esto suelen recurrir a una plataforma de tiempo real gestionada, que se encarga del ciclo de vida del token como parte del servicio.

Renovación de Tokens en Conexiones de Larga Duración

Una conexión WebSocket sobrevive a su token, por lo que es necesario definir una estrategia de renovación desde el principio. Existen dos opciones: renovar el token en banda sobre el socket abierto, o cerrar y reconectar con uno nuevo. Usa la renovación de tokens en banda — enviar un token actualizado sobre la conexión existente — cuando el socket gestiona suscripciones con estado u operaciones pendientes que se perderían al reconectar; usa cierre y reconexión cuando la conexión es sin estado y el cliente puede restablecer el contexto de forma económica sin pérdida de datos.

El cliente programa la renovación a partir de la propia expiración del JWT. El claim exp es un NumericDate en segundos desde la época Unix según el RFC 7519 §4.1.4, por lo que debes multiplicar por 1000 antes de comparar con Date.now().

// Navegador — programar renovación en banda 60s antes de la expiración, con reconexión como alternativa
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á en segundos
    const fireAt = exp * 1000 - Date.now() - 60_000; // 60s antes de la expiración
    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'); // recurrir a una reconexión limpia
      this.connect();
    }
  }
}

El servidor valida el token renovado y actualiza la sesión asociada al socket — sin cerrar la conexión, que es precisamente el objetivo de la renovación en banda:

// 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');
    }
  }
});

La Autorización Es una Verificación Continua

La autenticación con WebSocket no es un evento puntual al abrir la conexión: dado que las conexiones son de larga duración, un token válido en el momento del handshake puede expirar, ser revocado o perder los permisos asociados mientras el socket sigue abierto, lo que convierte la autorización por mensaje en un requisito de producción, no en una medida de seguridad opcional. Los permisos cambian durante la conexión por acciones administrativas, expiración de suscripciones o moderación — y el handshake realizado hace una hora no puede tener en cuenta nada de eso.

La solución es verificar la autorización en cada mensaje entrante, no solo en el momento de la conexión. Trata el handshake como el establecimiento de la identidad y cada mensaje como una decisión de autorización independiente:

// Servidor Node.js — verificación de permisos por mensaje
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);
});

Complementa esto con una verificación de revocación para que un usuario que ha cerrado sesión o ha sido bloqueado no pueda seguir operando sobre un socket ya abierto. Un Set en memoria con los IDs de tokens revocados funciona para un proceso único; se necesita un almacén compartido como Redis en cuanto se ejecutan múltiples nodos WebSocket, ya que la revocación debe ser visible para el nodo que mantiene la conexión.

Lista de Verificación de Seguridad y Depuración de Fallos Silenciosos

Asegurar una conexión WebSocket se reduce a una lista de comprobación concisa. Revísala antes de desplegar:

  • Usa solo wss://. El RFC 6455 §10.6 recomienda ejecutar WebSockets sobre TLS para garantizar confidencialidad e integridad. El uso de ws:// sin cifrar expone tokens y mensajes en tránsito.
  • Mantén los tokens de corta duración. La Hoja de Referencia de Gestión de Sesiones de OWASP recomienda tiempos de inactividad de 2–5 minutos para aplicaciones de alto valor y 15–30 minutos para las de menor riesgo; opta por el extremo corto para tokens almacenados en el navegador.
  • Impón un tiempo límite de autenticación en la autenticación por primer mensaje (5–10 segundos, por convención) y cierra los sockets sin autenticar.
  • Limita la tasa de conexiones por IP o por usuario para controlar el volumen de sockets sin autenticar. Cualquier cifra específica (como un máximo de conexiones simultáneas) es ilustrativa, no un estándar — establécela a partir de tu propia línea base de tráfico.
  • Implementa revocación. Verifica una lista de revocación al conectar y en cada mensaje para que una sesión cerrada no persista en un socket abierto.
  • Valida Origin en la autenticación basada en cookies, y establece SameSite=Strict o Lax.

Estos puntos son importantes porque los fallos de autenticación con WebSocket ocurren de forma silenciosa. Cuando un token de corta duración expira durante una sesión y el servidor cierra el socket, el navegador lanza un evento close; si la aplicación no lo gestiona, la interfaz puede seguir mostrando el estado “conectado” mientras cada mensaje posterior se descarta silenciosamente — un modo de fallo invisible en los logs del servidor pero visible en la reproducción de sesiones, donde se observa el último mensaje exitoso seguido de silencio en el lado del cliente. Lo mismo ocurre con el patrón de primer mensaje: en una conexión lenta, los mensajes encolados antes de que se complete la autenticación pueden descartarse si el tiempo límite de autenticación se activa primero, y la reproducción de sesiones de esas conexiones muestra al cliente enviando mensajes que nunca generan respuesta mientras el socket sigue aparentemente abierto. Reproducir el lado del cliente es a menudo la única forma de explicar una clase de errores que los logs del servidor solo muestran como un frame de cierre sin contexto.

Conclusión

Trata la ausencia de la cabecera Authorization como el punto de partida, no como el problema completo: elige un método de entrega siguiendo la regla de decisión anterior, define tu estrategia de renovación e implementa la autorización por mensaje antes de que la conexión sea de larga duración en producción. El handshake acredita quién se conectó; todo lo que viene después es donde reside la verdadera seguridad de WebSocket. Comienza auditando un socket existente — confirma que funciona sobre wss://, rechaza tokens expirados durante la sesión y comunica al usuario una conexión cerrada en lugar de descartar mensajes silenciosamente.

Preguntas Frecuentes

¿Puedo enviar una cabecera Authorization Bearer con una conexión WebSocket desde el navegador?

No. El constructor WebSocket del navegador solo acepta una URL y un array opcional de subprotocolos, por lo que no existe ninguna API para adjuntar una cabecera Authorization personalizada al handshake de apertura. Las cuatro alternativas prácticas son: un token en la cadena de consulta, una cookie de sesión que el navegador envía automáticamente, una credencial codificada en el valor del subprotocolo Sec-WebSocket-Protocol, o una credencial enviada como primer mensaje tras abrir la conexión. Las librerías WebSocket nativas fuera del navegador, como los clientes de Node.js, pueden establecer cabeceras arbitrarias, pero el código del navegador no puede.

¿Qué ocurre con los mensajes enviados antes de que se complete la autenticación por primer mensaje?

Los mensajes enviados antes de que se complete el handshake AUTH deben encolarse en el cliente y enviarse solo después de que el servidor confirme la autenticación, ya que cualquier mensaje enviado antes puede descartarse silenciosamente. Si el tiempo límite de autenticación del servidor se activa antes de recibir credenciales válidas — típicamente en una ventana de 5 a 10 segundos — la conexión se cierra y cualquier mensaje en tránsito o en cola se pierde. En conexiones lentas, esto produce un fallo en el que el socket sigue apareciendo abierto mientras los mensajes nunca reciben respuesta. Siempre condiciona los envíos de la aplicación a un indicador de autenticación completada.

¿Debo renovar un token WebSocket en banda o cerrar y reconectar?

Usa la renovación en banda — enviando un token actualizado sobre el socket existente — cuando la conexión gestiona suscripciones con estado u operaciones pendientes que se perderían al reconectar. Usa cierre y reconexión cuando la conexión es sin estado y el cliente puede restablecer el contexto de forma económica sin pérdida de datos. La elección es arquitectónica, no estilística: reconectar destruye el estado de suscripción en el servidor, por lo que para un feed en vivo con muchos canales activos, la renovación en banda evita la sobrecarga de resuscripción y la pérdida de eventos durante el intervalo.

¿Por qué mi interfaz WebSocket muestra 'conectado' después de que el token expira?

El servidor cierra el socket cuando el token expira y el navegador lanza un evento close, pero si la gestión de estado de la aplicación no maneja ese evento, la interfaz sigue mostrando el estado conectado mientras cada mensaje posterior se descarta silenciosamente. Este fallo es invisible en los logs del servidor, que solo registran un frame de cierre sin contexto, pero visible en la reproducción de sesiones como el último mensaje exitoso seguido de silencio en el lado del cliente. Gestiona el evento close explícitamente para comunicar la desconexión al usuario y activar la reconexión.

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.