hectoday
DocsCoursesChangelog GitHub
DocsCoursesChangelog GitHub

Access Required

Enter your access code to view courses.

Invalid code

← All courses Real-Time APIs with @hectoday/http

Beyond Request-Response

  • Why Real-Time Matters
  • Project Setup

Polling

  • Short Polling
  • Long Polling

Server-Sent Events

  • How SSE Works
  • Building an SSE Endpoint
  • Event Types and IDs
  • SSE in Practice

WebSockets

  • How WebSockets Work
  • Building a WebSocket Server
  • Rooms and Broadcasting
  • Authentication on WebSockets
  • Handling Disconnects and Reconnection

Patterns and Architecture

  • Pub/Sub
  • Presence
  • Scaling Real-Time

Putting It All Together

  • Choosing the Right Approach
  • Capstone: Live Task Board

Authentication on WebSockets

The authentication problem

HTTP requests carry cookies automatically. Every REST request includes the session cookie, and the server authenticates via the cookie.

WebSocket connections do not work the same way. The initial HTTP upgrade request can include cookies (the browser sends them), but after the upgrade, the connection is no longer HTTP. There are no headers, no cookies, no standard authentication mechanism.

You need to authenticate the WebSocket connection at the start and associate it with a user.

Three approaches

Cookie-based (during upgrade). The browser sends cookies with the upgrade request. The server reads the cookie, validates the session, and associates the socket with the user.

Token in URL. The client includes a token in the WebSocket URL: ws://localhost:3000/ws?token=abc123. The server validates the token during the connection setup.

Token as first message. The client connects first, then sends an authentication message: {"type":"auth","token":"abc123"}. The server validates and either accepts or closes the connection.

Cookie-based authentication

The simplest approach for browser clients. The browser sends cookies with the upgrade request:

// src/ws-server.ts
import { parseCookies } from "./cookies.js";
import { getSession } from "./sessions.js";
import db from "./db.js";

const socketUsers = new Map<WebSocket, { userId: string; name: string }>();

wss.on("connection", (ws, request) => {
  // Read session cookie from the upgrade request
  const cookies = parseCookies(request.headers.cookie ?? "");
  const sessionId = cookies.session;

  if (!sessionId) {
    ws.send(JSON.stringify({ error: "Unauthorized" }));
    ws.close(4001, "Unauthorized");
    return;
  }

  const session = getSession(sessionId);
  if (!session) {
    ws.send(JSON.stringify({ error: "Invalid session" }));
    ws.close(4001, "Invalid session");
    return;
  }

  const user = db.prepare("SELECT id, name FROM users WHERE id = ?").get(session.userId) as any;
  if (!user) {
    ws.close(4001, "User not found");
    return;
  }

  // Associate the socket with the user
  socketUsers.set(ws, { userId: user.id, name: user.name });

  ws.send(
    JSON.stringify({
      type: "authenticated",
      user: { id: user.id, name: user.name },
    }),
  );

  ws.on("close", () => {
    socketUsers.delete(ws);
    leaveAllRooms(ws);
  });

  ws.on("message", (raw) => {
    const userInfo = socketUsers.get(ws);
    if (!userInfo) {
      ws.close(4001);
      return;
    }

    try {
      const message = JSON.parse(raw.toString());
      handleMessage(ws, message, userInfo);
    } catch {
      ws.send(JSON.stringify({ error: "Invalid JSON" }));
    }
  });
});

The message handler now receives the user info:

function handleMessage(ws: WebSocket, message: any, user: { userId: string; name: string }): void {
  switch (message.type) {
    case "join":
      joinRoom(message.boardId, ws);
      broadcastToRoom(
        message.boardId,
        {
          type: "user_joined",
          user: { id: user.userId, name: user.name },
          viewers: getRoomSize(message.boardId),
        },
        ws,
      );
      break;

    case "typing":
      broadcastToRoom(
        message.boardId,
        {
          type: "user_typing",
          user: { id: user.userId, name: user.name },
        },
        ws,
      );
      break;

    // ...
  }
}

Token in URL (for non-browser clients)

API clients and mobile apps cannot easily send cookies with WebSocket connections. Instead, include a token in the URL:

// Client-side
const ws = new WebSocket(`ws://localhost:3000/ws?token=${apiKey}`);
// Server-side — validate during connection
wss.on("connection", (ws, request) => {
  const url = new URL(request.url ?? "", "http://localhost:3000");
  const token = url.searchParams.get("token");

  if (token) {
    // Validate API key or JWT
    const user = validateToken(token);
    if (!user) {
      ws.close(4001);
      return;
    }
    socketUsers.set(ws, user);
  } else {
    // Fall back to cookie-based auth
    // ...
  }
});

[!WARNING] Tokens in URLs appear in server logs, proxy logs, and browser history. For sensitive tokens, prefer the cookie approach or the first-message approach. For API keys that are already known to the client, URL tokens are acceptable.

Close codes

WebSocket close codes indicate why the connection was closed:

  • 1000 — Normal closure
  • 1001 — Going away (browser navigating)
  • 4001 — Unauthorized (custom code, 4000-4999 range is for application use)
  • 4002 — Session expired
  • 4003 — Forbidden

Use custom codes (4000+) to communicate specific application-level close reasons.

Exercises

Exercise 1: Implement cookie-based WebSocket authentication. Log in via REST to get a session cookie, then connect to the WebSocket. Verify the authenticated message includes the user info.

Exercise 2: Try connecting without a session cookie. Verify the connection is closed with code 4001.

Exercise 3: Implement the typing message type. When Alice sends {"type":"typing","boardId":"board-1"}, Bob should receive {"type":"user_typing","user":{"id":"user-alice","name":"Alice"}}.

Why is cookie-based auth simpler than token-based auth for browser WebSocket clients?

← Rooms and Broadcasting Handling Disconnects and Reconnection →

© 2026 hectoday. All rights reserved.