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 closure1001— Going away (browser navigating)4001— Unauthorized (custom code, 4000-4999 range is for application use)4002— Session expired4003— 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?