tests.ws

WebSocket Authentication Methods

websocket authentication security javascript nodejs

WebSocket has no built-in authentication mechanism. The protocol provides an upgrade handshake, but no standard way to verify who is connecting. You need to add authentication yourself, and there are several practical approaches.

This guide covers four methods for authenticating WebSocket connections, each with working code, trade-offs, and guidance on when to pick one over another. Every method here has been used in production systems, and the right choice depends on your architecture and threat model.

Why WebSocket Auth Is Different from HTTP

If you have worked with REST APIs, you are used to sending an Authorization header with every request. The browser WebSocket API does not support custom headers. When you call new WebSocket('wss://example.com/ws'), the browser sends the HTTP upgrade request on your behalf, and you cannot attach headers like Authorization: Bearer <token>.

Here is what the browser does send automatically:

  • Cookies for the target domain
  • The Origin header
  • Standard headers like Host and Sec-WebSocket-Key

That is it. No custom headers, no bearer tokens in headers, no basic auth from JavaScript. This constraint shapes every authentication strategy for browser-based WebSocket clients.

Non-browser clients (like a Node.js process or a mobile app) can set custom headers during the handshake. But if you need to support browsers, you need one of the four methods below. For more background on the handshake itself, see the WebSocket handshake guide.

Method 1: Token in Query String

The simplest approach is to append your authentication token to the WebSocket URL as a query parameter.

Client Code

const token = getAuthToken(); // your JWT or session token
const ws = new WebSocket(`wss://example.com/ws?token=${token}`);

ws.addEventListener('open', () => {
  console.log('Connected and authenticated');
});

ws.addEventListener('close', (event) => {
  if (event.code === 4001) {
    console.log('Authentication failed');
  }
});

Server Code (Node.js with ws)

import { WebSocketServer } from 'ws';
import { createServer } from 'http';
import jwt from 'jsonwebtoken';

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

server.on('upgrade', (request, socket, head) => {
  const url = new URL(request.url, `http://${request.headers.host}`);
  const token = url.searchParams.get('token');

  if (!token) {
    socket.write('HTTP/1.1 401 Unauthorized\r\n\r\n');
    socket.destroy();
    return;
  }

  try {
    const user = jwt.verify(token, process.env.JWT_SECRET);
    wss.handleUpgrade(request, socket, head, (ws) => {
      ws.user = user;
      wss.emit('connection', ws, request);
    });
  } catch (err) {
    socket.write('HTTP/1.1 401 Unauthorized\r\n\r\n');
    socket.destroy();
  }
});

wss.on('connection', (ws) => {
  console.log(`User connected: ${ws.user.sub}`);
});

server.listen(8080);

The key detail here: authentication happens before the WebSocket upgrade completes. If the token is invalid, the server rejects the HTTP request and no WebSocket connection is established.

Pros

  • Simple to implement on both client and server
  • Works in every browser and every WebSocket library
  • Authentication happens before the connection opens

Cons

  • The token appears in the URL, which means it can be logged by proxies, load balancers, and web servers
  • Browser history and developer tools will show the token
  • Server access logs will contain the token

Mitigation

Use short-lived tokens (30 to 60 seconds) that are generated right before connecting. Single-use tokens are even better. If a logged token is discovered later, it will already be expired or consumed. Method 4 (ticket exchange) formalizes this pattern.

If your application already uses cookie-based sessions (common with frameworks like Express, Django, or Rails), the browser will send those cookies during the WebSocket handshake automatically.

Client Code

// No special auth code needed. The browser sends cookies automatically.
const ws = new WebSocket('wss://example.com/ws');

ws.addEventListener('open', () => {
  console.log('Connected with session cookie');
});

Server Code (Node.js with Express Session)

import { WebSocketServer } from 'ws';
import { createServer } from 'http';
import express from 'express';
import session from 'express-session';
import cookie from 'cookie';
import cookieSignature from 'cookie-signature';

const app = express();
const server = createServer(app);
const wss = new WebSocketServer({ noServer: true });

const sessionStore = new session.MemoryStore();
const SESSION_SECRET = process.env.SESSION_SECRET;

app.use(session({
  store: sessionStore,
  secret: SESSION_SECRET,
  resave: false,
  saveUninitialized: false,
}));

server.on('upgrade', (request, socket, head) => {
  // Validate Origin header to prevent Cross-Site WebSocket Hijacking
  const origin = request.headers.origin;
  if (origin !== 'https://example.com') {
    socket.write('HTTP/1.1 403 Forbidden\r\n\r\n');
    socket.destroy();
    return;
  }

  // Parse the session cookie
  const cookies = cookie.parse(request.headers.cookie || '');
  const sid = cookies['connect.sid'];

  if (!sid) {
    socket.write('HTTP/1.1 401 Unauthorized\r\n\r\n');
    socket.destroy();
    return;
  }

  // Unsign the session ID
  const sessionId = cookieSignature.unsign(sid.slice(2), SESSION_SECRET);
  if (!sessionId) {
    socket.write('HTTP/1.1 401 Unauthorized\r\n\r\n');
    socket.destroy();
    return;
  }

  // Look up the session
  sessionStore.get(sessionId, (err, sessionData) => {
    if (err || !sessionData || !sessionData.userId) {
      socket.write('HTTP/1.1 401 Unauthorized\r\n\r\n');
      socket.destroy();
      return;
    }

    wss.handleUpgrade(request, socket, head, (ws) => {
      ws.userId = sessionData.userId;
      wss.emit('connection', ws, request);
    });
  });
});

server.listen(8080);

The Origin Check Is Mandatory

Without the Origin header check, your WebSocket endpoint is vulnerable to Cross-Site WebSocket Hijacking (CSWSH). Here is what happens without it:

  1. A user is logged into your site and has a valid session cookie
  2. The user visits a malicious site
  3. The malicious site opens a WebSocket to your server: new WebSocket('wss://yoursite.com/ws')
  4. The browser sends the user’s session cookie automatically
  5. Your server accepts the connection, thinking the user initiated it

The Origin check prevents this because the malicious site’s origin will not match your allowed origins.

Pros

  • Zero client-side authentication code
  • Works with existing session infrastructure
  • Familiar pattern for teams already using cookie-based auth

Cons

  • Vulnerable to CSWSH if you skip the Origin check
  • Cookies are domain-bound, making cross-domain connections difficult
  • Does not work well for non-browser clients

Method 3: First Message Authentication

With this approach, the WebSocket connection opens without authentication. The client immediately sends an authentication message, and the server validates it before processing any other messages.

Client Code

const ws = new WebSocket('wss://example.com/ws');

ws.addEventListener('open', () => {
  // Send auth as the very first message
  ws.send(JSON.stringify({
    type: 'auth',
    token: getAuthToken()
  }));
});

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

  if (msg.type === 'auth_success') {
    console.log('Authenticated, ready to send messages');
  }

  if (msg.type === 'auth_failed') {
    console.log('Authentication failed');
    ws.close();
  }
});

Server Code

import { WebSocketServer } from 'ws';
import jwt from 'jsonwebtoken';

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

wss.on('connection', (ws) => {
  ws.isAuthenticated = false;
  ws.messageQueue = [];

  // Set a timeout: if no auth within 5 seconds, disconnect
  const authTimeout = setTimeout(() => {
    if (!ws.isAuthenticated) {
      ws.send(JSON.stringify({ type: 'auth_failed', reason: 'timeout' }));
      ws.close(4001, 'Authentication timeout');
    }
  }, 5000);

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

    // Handle auth message
    if (!ws.isAuthenticated) {
      if (msg.type !== 'auth') {
        // Queue messages received before auth, or reject them
        ws.send(JSON.stringify({ type: 'error', reason: 'Not authenticated' }));
        return;
      }

      try {
        const user = jwt.verify(msg.token, process.env.JWT_SECRET);
        ws.isAuthenticated = true;
        ws.user = user;
        clearTimeout(authTimeout);
        ws.send(JSON.stringify({ type: 'auth_success', userId: user.sub }));
      } catch (err) {
        ws.send(JSON.stringify({ type: 'auth_failed', reason: 'Invalid token' }));
        ws.close(4001, 'Invalid token');
      }
      return;
    }

    // Handle normal messages after authentication
    handleMessage(ws, msg);
  });
});

function handleMessage(ws, msg) {
  // Your application logic here
  console.log(`Message from ${ws.user.sub}:`, msg);
}

Pros

  • Clean URLs with no tokens in query strings
  • No dependency on cookies
  • Works well for both browser and non-browser clients
  • Flexible, as you can pass any auth data in the message

Cons

  • The connection exists before authentication completes, consuming server resources
  • You must implement a timeout to close unauthenticated connections
  • Every message handler needs to check authentication state
  • Slightly more code on both client and server

This method is popular with JavaScript WebSocket clients because it keeps the connection URL clean and works naturally with token-based auth systems.

Method 4: Ticket/Token Exchange (Two-Step)

This is the most secure browser-compatible method. The client first makes an authenticated HTTP request to get a one-time ticket, then uses that ticket to open the WebSocket connection.

Flow

Client                          Server
  |                               |
  |-- POST /api/ws-ticket ------->|  (with Authorization header)
  |<-- { ticket: "abc123" } ------|  (one-time ticket, expires in 30s)
  |                               |
  |-- WS wss://example.com/ws?ticket=abc123 -->|
  |<-- Connection established ----|  (ticket consumed, cannot be reused)

Server Code: Ticket Endpoint

import express from 'express';
import crypto from 'crypto';

const app = express();
const ticketStore = new Map();

// Authenticated HTTP endpoint to issue tickets
app.post('/api/ws-ticket', authenticateHTTP, (req, res) => {
  const ticket = crypto.randomBytes(32).toString('hex');

  ticketStore.set(ticket, {
    userId: req.user.id,
    roles: req.user.roles,
    createdAt: Date.now(),
  });

  // Expire the ticket after 30 seconds
  setTimeout(() => ticketStore.delete(ticket), 30000);

  res.json({ ticket });
});

function authenticateHTTP(req, res, next) {
  const authHeader = req.headers.authorization;
  if (!authHeader || !authHeader.startsWith('Bearer ')) {
    return res.status(401).json({ error: 'Missing token' });
  }

  try {
    req.user = jwt.verify(authHeader.split(' ')[1], process.env.JWT_SECRET);
    next();
  } catch {
    res.status(401).json({ error: 'Invalid token' });
  }
}

Server Code: WebSocket Upgrade

import { WebSocketServer } from 'ws';
import { createServer } from 'http';

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

server.on('upgrade', (request, socket, head) => {
  const url = new URL(request.url, `http://${request.headers.host}`);
  const ticket = url.searchParams.get('ticket');

  if (!ticket || !ticketStore.has(ticket)) {
    socket.write('HTTP/1.1 401 Unauthorized\r\n\r\n');
    socket.destroy();
    return;
  }

  // Consume the ticket (single use)
  const userData = ticketStore.get(ticket);
  ticketStore.delete(ticket);

  wss.handleUpgrade(request, socket, head, (ws) => {
    ws.user = userData;
    wss.emit('connection', ws, request);
  });
});

wss.on('connection', (ws) => {
  console.log(`Authenticated user: ${ws.user.userId}`);
});

server.listen(8080);

Client Code

async function connectWebSocket() {
  // Step 1: Get a one-time ticket via authenticated HTTP
  const response = await fetch('https://example.com/api/ws-ticket', {
    method: 'POST',
    headers: {
      'Authorization': `Bearer ${getAuthToken()}`,
      'Content-Type': 'application/json',
    },
  });

  if (!response.ok) {
    throw new Error('Failed to get WebSocket ticket');
  }

  const { ticket } = await response.json();

  // Step 2: Connect with the ticket
  const ws = new WebSocket(`wss://example.com/ws?ticket=${ticket}`);

  ws.addEventListener('open', () => {
    console.log('Connected and authenticated via ticket');
  });

  return ws;
}

Pros

  • The real auth token never appears in WebSocket URLs or logs
  • Tickets are single-use, so a logged ticket cannot be replayed
  • Tickets expire quickly, limiting the attack window
  • Authentication uses the standard HTTP Authorization header

Cons

  • Requires an extra HTTP request before each WebSocket connection
  • More code to implement and maintain
  • The ticket store needs cleanup logic (or use Redis with TTL)

Comparison Table

MethodSecurityComplexityBrowser SupportNon-Browser Support
Query String TokenLow to MediumLowYesYes
Cookie-BasedMedium (with Origin check)MediumYesLimited
First MessageMediumMediumYesYes
Ticket ExchangeHighHighYesYes

For most applications, ticket exchange provides the best security. If you are building an internal tool or a prototype, query string tokens get you running quickly. If you already have cookie-based sessions, cookie-based auth adds WebSocket support with minimal changes. For details on other security considerations, check the WebSocket security guide.

Refreshing Authentication

WebSocket connections can stay open for hours or days. Tokens expire. You need a strategy for handling authentication expiration on long-lived connections.

Server-Initiated Disconnect

The server tracks token expiration and sends a message before closing:

wss.on('connection', (ws) => {
  const tokenExp = ws.user.exp * 1000; // JWT exp is in seconds
  const timeUntilExpiry = tokenExp - Date.now();

  // Warn the client 60 seconds before expiration
  const warnTimer = setTimeout(() => {
    ws.send(JSON.stringify({
      type: 'auth_expiring',
      expiresIn: 60,
    }));
  }, timeUntilExpiry - 60000);

  // Close the connection when the token expires
  const closeTimer = setTimeout(() => {
    ws.close(4002, 'Token expired');
  }, timeUntilExpiry);

  ws.on('close', () => {
    clearTimeout(warnTimer);
    clearTimeout(closeTimer);
  });
});

Client Reconnect Strategy

The client listens for the expiration warning and reconnects with a fresh token:

function createConnection() {
  const ws = new WebSocket(`wss://example.com/ws?token=${getAuthToken()}`);

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

    if (msg.type === 'auth_expiring') {
      // Refresh the auth token
      refreshAuthToken().then(() => {
        // Reconnect with the new token
        ws.close(1000, 'Reconnecting with fresh token');
        createConnection();
      });
    }
  });

  ws.addEventListener('close', (event) => {
    if (event.code === 4002) {
      // Token expired, get a new one and reconnect
      refreshAuthToken().then(() => createConnection());
    }
  });

  return ws;
}

A common pattern is to refresh the token proactively and reconnect before the server forces a disconnect. This avoids any gap in connectivity.

Authorization After Authentication

Authentication answers “who are you?” Authorization answers “what are you allowed to do?” After a WebSocket connection is authenticated, you often need per-action or per-channel permissions.

Per-Channel Authorization

wss.on('connection', (ws) => {
  ws.on('message', (data) => {
    const msg = JSON.parse(data);

    if (msg.type === 'subscribe') {
      // Check if the user can access this channel
      if (!canAccessChannel(ws.user, msg.channel)) {
        ws.send(JSON.stringify({
          type: 'error',
          reason: `Not authorized for channel: ${msg.channel}`,
        }));
        return;
      }

      addToChannel(ws, msg.channel);
      ws.send(JSON.stringify({ type: 'subscribed', channel: msg.channel }));
    }

    if (msg.type === 'publish') {
      // Check if the user can publish to this channel
      if (!canPublishToChannel(ws.user, msg.channel)) {
        ws.send(JSON.stringify({
          type: 'error',
          reason: `Not authorized to publish to: ${msg.channel}`,
        }));
        return;
      }

      broadcastToChannel(msg.channel, msg.data, ws);
    }
  });
});

function canAccessChannel(user, channel) {
  // Example: admins can access everything, users can access their own channels
  if (user.roles.includes('admin')) return true;
  if (channel.startsWith(`user:${user.id}:`)) return true;
  if (channel.startsWith('public:')) return true;
  return false;
}

function canPublishToChannel(user, channel) {
  if (user.roles.includes('admin')) return true;
  if (channel.startsWith(`user:${user.id}:`)) return true;
  return false;
}

Authorization checks should run on every action, not just at connection time. A user’s permissions can change during a long-lived connection. For a deeper look at building a Node.js WebSocket server with these patterns, see the Node.js ws guide.

  • WebSocket Security covers encryption, input validation, rate limiting, and other security topics beyond authentication
  • WebSocket Handshake explains the upgrade process in detail, which is where most authentication methods hook in
  • Node.js ws Guide walks through building a production WebSocket server with the ws library
  • JavaScript WebSocket Guide covers the browser WebSocket API, including reconnection and error handling