All parts · Chat at Scale
Real-Time Chat at Scale — Part 03Security

Authenticating WebSocket Connections

Verify JWTs during the Socket.IO handshake, authorize every room join, handle tokens that expire on hours-long connections and block cross-site WebSocket hijacking.

Syed Wajeeh Haider
Syed Wajeeh Haider
Full Stack Software Engineer
Sep 28, 20267 min read
#Security#JWT#Socket.IO#Authentication

Right now Relay trusts whatever user ID the client claims, which means anyone can read and write anyone's messages. Authentication for WebSockets looks like authentication for HTTP but has a few twists: the browser API cannot set headers, a connection can outlive the token that opened it, and the same-origin protections you rely on for fetch do not apply. In this part we close all three gaps.

Why headers and cookies are awkward

With fetch you would send Authorization: Bearer <token>. The browser WebSocket constructor takes a URL and an optional list of subprotocols, and nothing else, so you cannot add custom headers. That leaves three options:

  • —Cookies. The browser sends cookies with the upgrade request automatically. It works well for same-site apps, but it opens the door to cross-site WebSocket hijacking (more on that below) and gets messy across domains and in mobile apps.
  • —Query string. wss://relay.example.com/?token=... works everywhere, but URLs end up in load balancer logs, proxy logs and monitoring tools. Avoid it for long-lived tokens.
  • —First message or handshake payload. Send the token as data once the connection is open. Socket.IO builds this in as the auth option, sent in the first packet of the connection rather than the URL.

Relay uses the Socket.IO auth payload with a short-lived access token issued by the existing HTTP login flow. Pass auth as a function rather than an object: Socket.IO calls it on every connection attempt, including reconnects, so it always sends the freshest token.

ts
import { io, type Socket } from 'socket.io-client';
import { getAccessToken } from './auth';
import type { ClientToServerEvents, ServerToClientEvents } from '../../shared/events';

export const socket: Socket<ServerToClientEvents, ClientToServerEvents> = io(
  import.meta.env.VITE_RELAY_URL,
  {
    autoConnect: false,
    transports: ['websocket', 'polling'],
    // Called on every (re)connect, so an expired token is never reused.
    auth: async (cb) => cb({ token: await getAccessToken() }),
  },
);
client/src/socket.ts

Verifying the token in middleware

Socket.IO middleware registered with io.use() runs once per connection, before the connection event fires. If it calls next(err), the connection is refused and the client receives a connect_error with that error. This is the right place for authentication: an unauthenticated socket never exists as far as the rest of your code is concerned.

ts
import { jwtVerify, errors } from 'jose';
import type { Socket } from 'socket.io';

const secret = new TextEncoder().encode(process.env.JWT_SECRET!);

export type AuthUser = { id: string; exp: number };

export async function verifyToken(token: unknown): Promise<AuthUser> {
  if (typeof token !== 'string' || token.length > 4096) throw new Error('unauthorized');

  const { payload } = await jwtVerify(token, secret, {
    issuer: 'https://api.relay.example.com',
    audience: 'relay-realtime',
    algorithms: ['HS256'],
  });

  if (!payload.sub || !payload.exp) throw new Error('unauthorized');
  return { id: payload.sub, exp: payload.exp };
}

export async function authMiddleware(socket: Socket, next: (err?: Error) => void) {
  try {
    const user = await verifyToken(socket.handshake.auth.token);
    socket.data.userId = user.id;
    socket.data.tokenExp = user.exp;
    next();
  } catch (err) {
    const e = new Error(err instanceof errors.JWTExpired ? 'token_expired' : 'unauthorized');
    next(e);
  }
}
server/src/auth.ts: JWT verification with jose

Pin the algorithm list, issuer and audience. Leaving algorithms open is a classic JWT mistake, and a dedicated audience such as relay-realtime means a token minted for some other service cannot be replayed against your socket server. The error message you pass to next reaches the client, so keep it to a short machine-readable code and never include stack traces.

ts
import { authMiddleware } from './auth';

io.use(authMiddleware);

io.on('connection', async (socket) => {
  const { userId } = socket.data;

  // A personal room reaches every tab and device this user has open.
  await socket.join(`user:${userId}`);

  scheduleExpiry(socket);
  registerHandlers(socket);
});
server/src/index.ts: wiring it up

Extend SocketData in the shared types with tokenExp: number so socket.data stays typed. The user:<userId> room is the address for anything aimed at a person rather than a conversation: new conversation invites, read receipts from their other devices, or a forced logout.

Authorize every room join

Authentication tells you who the user is. It says nothing about which conversations they may read. The conversation:join handler from part 2 happily joins any room ID the client asks for, which is the WebSocket equivalent of an IDOR bug. Every join must check membership, and every send must check it too, because a client can emit message:send for a conversation it never joined.

ts
import { isMember } from './messages';

socket.on('conversation:join', async (conversationId, ack) => {
  if (!(await isMember(conversationId, socket.data.userId))) {
    return ack({ ok: false, error: 'forbidden' });
  }
  await socket.join(`conv:${conversationId}`);
  ack({ ok: true, data: null });
});

socket.on('message:send', async (payload, ack) => {
  const parsed = sendSchema.safeParse(payload);
  if (!parsed.success) return ack({ ok: false, error: 'invalid_payload' });

  // Being in the room is not proof of membership: check again.
  if (!socket.rooms.has(`conv:${parsed.data.conversationId}`)) {
    return ack({ ok: false, error: 'not_joined' });
  }
  // ...save and broadcast
});
Membership checks on join and send

Checking socket.rooms on send is cheap and safe because only the server can add a socket to a room, and it only does so after the membership query. When a user is removed from a conversation, remove their sockets from the room as well with io.in("user:<id>").socketsLeave("conv:<id>"), otherwise they keep receiving messages until they reconnect.

Tokens expire, connections do not

An HTTP request that carries a 15-minute token is checked every time. A WebSocket is checked once, at connect, and may then stay open for eight hours. If you revoke a user or their token expires, the socket keeps working. There are two common fixes, and Relay uses both.

1. Disconnect when the token expires

ts
function scheduleExpiry(socket: RelaySocket) {
  const msLeft = socket.data.tokenExp * 1000 - Date.now();
  const timer = setTimeout(() => {
    socket.emit('session:expired');
    socket.disconnect(true);
  }, Math.max(msLeft, 0));
  socket.on('disconnect', () => clearTimeout(timer));
}

Because the client's auth option is a function, handling this is simple: on session:expired, refresh the access token through your normal HTTP refresh flow and call socket.connect(). The reconnect sends the new token automatically. The gap is a few hundred milliseconds, and part 5 makes sure no messages are lost across it.

ts
socket.on('session:expired', async () => {
  await refreshAccessToken(); // your HTTP refresh-token endpoint
  socket.connect();
});

socket.on('connect_error', async (err) => {
  if (err.message === 'token_expired') {
    await refreshAccessToken();
    socket.connect();
  } else if (err.message === 'unauthorized') {
    redirectToLogin();
  }
});
client: reconnect with a fresh token

2. Kick revoked users immediately

For logouts, password changes and bans you cannot wait for expiry. Because every socket joins user:<userId>, forcing a user off every device is one line: io.in("user:" + userId).disconnectSockets(true). In part 7 this call works across all servers thanks to the Redis adapter.

Cross-site WebSocket hijacking

WebSockets are not subject to CORS. If your auth relies on cookies, a malicious page on evil.example can open new WebSocket('wss://relay.example.com'), the browser attaches your cookies, and the attacker now has an authenticated socket. This is called cross-site WebSocket hijacking (CSWSH).

Token-in-payload auth largely avoids it, since the attacker's page cannot read your token. Still, check Origin on the upgrade as defence in depth. Socket.IO's cors option only governs the HTTP polling requests; for the WebSocket upgrade, use allowRequest, which runs on every handshake request before anything else.

ts
const ALLOWED_ORIGINS = new Set(['https://relay.example.com', 'https://app.relay.example.com']);

const io = new Server(httpServer, {
  cors: { origin: [...ALLOWED_ORIGINS], credentials: true },
  allowRequest: (req, callback) => {
    const origin = req.headers.origin;
    // Native mobile clients send no Origin; they authenticate by token only.
    callback(null, !origin || ALLOWED_ORIGINS.has(origin));
  },
});
Rejecting unknown origins

Limit connection attempts

Every handshake costs a TLS negotiation, a JWT verification and some memory. A buggy client stuck in a reconnect loop, or a deliberate flood, can burn a surprising amount of CPU. Rate limit connection attempts per IP at the edge (your load balancer or reverse proxy) and per user in the middleware. A fixed-window counter in Redis is enough for this.

ts
io.use(async (socket, next) => {
  const ip = (socket.handshake.headers['x-forwarded-for'] as string)?.split(',')[0].trim()
    ?? socket.handshake.address;
  const key = `conn:${ip}:${Math.floor(Date.now() / 60_000)}`;
  const count = await redis.incr(key);
  if (count === 1) await redis.expire(key, 60);
  if (count > 30) return next(new Error('rate_limited'));
  next();
});
io.use(authMiddleware);
Per-IP connection throttle in middleware

Only trust X-Forwarded-For when your load balancer sets it and clients cannot reach the server directly. Rate limiting strategies in general are covered in part 7 of the Scaling series; part 10 of this series adds per-user limits on messages themselves.

Key takeaways

  • —Send tokens in the Socket.IO auth payload, as a function so reconnects always use a fresh one.
  • —Verify JWTs in io.use middleware with pinned algorithms, issuer and audience.
  • —Authentication is not authorization: check membership on every join and every send.
  • —Connections outlive tokens; disconnect on expiry and reconnect after a refresh.
  • —Use the user:<id> room to push to all of a user's devices, or to kick them instantly.
  • —Check Origin with allowRequest and throttle connection attempts per IP.

Next up, part 4: Storing Messages: Schema, IDs and Pagination, where Relay gets a Postgres schema, time-sortable message IDs, idempotent writes and fast history loading.

The series — Real-Time Chat at Scale
(10)Let's talk

Ready to scaleyour application?

Stop worrying about technical debt. I can help you build a reliable, high-performance product from the ground up, tailored exactly to your business needs.

Actively open for new opportunities
Get in touch
Local time
--:-- PKT
© 2026 Syed Wajeeh Haider. All rights reserved.