PHASE 5 — Topic 18: Authentication — Making Sure Only Logged-In Users Can Connect

So far, anyone who knows your app's URL can open a Socket.IO connection. For a real application, this is a problem. In this post, we secure the connection itself using JWT-based authentication, checked before a client is even allowed to connect.

Where Authentication Happens: Middleware

Socket.IO supports middleware at the connection level, using io.use(). This runs once, for every socket, before the connection event fires. If the middleware rejects the connection, the client never actually connects, it receives a connect_error instead.


    io.use((socket, next) => {
        next();
    });

Calling next() with no argument allows the connection. Calling next(new Error("message")) rejects it.

Sending Credentials From the Client

The client sends authentication data through the auth option when creating the connection, not through a normal event:


    import { io } from "socket.io-client";

    const socket = io({
        path: "/api/socket",
        auth: {
            token: userToken,
        },
    });

This auth object is sent once, during the initial handshake, and is available on the server as socket.handshake.auth.

Writing the Authentication Middleware

Here is a complete JWT-based middleware, assuming you already have a JWT issued elsewhere in your app, for example, after a normal login form:


    import jwt from "jsonwebtoken";

    interface DecodedToken {
        userId: string;
        username: string;
    }

    io.use((socket, next) => {
        const token = socket.handshake.auth?.token;

        if (!token) {
            return next(new Error("Authentication error: token required"));
        }

        try {
            const decoded = jwt.verify(
                token,
                process.env.JWT_SECRET as string
            ) as DecodedToken;

            socket.data.userId = decoded.userId;
            next();
        } catch (err) {
            next(new Error("Authentication error: invalid token"));
        }
    });

A few important details here:

  • socket.handshake.auth?.token reads the token the client sent
  • jwt.verify() throws if the token is invalid or expired, which we catch and reject with next(new Error(...))
  • On success, we store the decoded user ID on socket.data, this is exactly the SocketData interface we defined back in Phase 2, so this assignment is fully typed
  • Since socket.data persists for the lifetime of the connection, every event handler later can access socket.data.userId without re-verifying the token each time

Installing jsonwebtoken


    npm install jsonwebtoken
    npm install --save-dev @types/jsonwebtoken

Handling the Rejection on the Client

If the middleware rejects the connection, the client receives a connect_error event, with the error message you passed to next():


    socket.on("connect_error", (err) => {
        console.log("Connection failed:", err.message);
    });

This is a good place to redirect a user back to a login page, or show an error message, if their token is missing or has expired.

Using the Authenticated User ID in Your Events

Once middleware succeeds, socket.data.userId is available everywhere inside that connection's handlers, so you no longer need clients to tell you who they are, you already know, and can trust it:


    io.on("connection", (socket) => {
        socket.join(`user:${socket.data.userId}`);

        socket.on("chatMessage", (data) => {
            io.emit("newMessage", {
                id: `${socket.id}-${Date.now()}`,
                text: data.text,
                sender: socket.data.userId,
                timestamp: Date.now(),
            });
        });
    });

This is a meaningful improvement over our earlier chat app, which trusted whatever sender name the client sent in the message payload itself. That approach let anyone impersonate anyone. Now, the sender identity comes from the verified token, not from client-supplied data.

Authenticating Specific Namespaces Only

Recall from Topic 15 that namespaces support their own middleware. This lets you apply authentication selectively, for example, requiring auth only on an /admin namespace, while keeping your public / namespace open:


    const adminNamespace = io.of("/admin");

    adminNamespace.use((socket, next) => {
        const token = socket.handshake.auth?.token;

        if (!token) {
            return next(new Error("Authentication error"));
        }

        try {
            const decoded = jwt.verify(token, process.env.JWT_SECRET as string) as {
                userId: string;
                role: string;
            };

            if (decoded.role !== "admin") {
                return next(new Error("Not authorized"));
            }

            next();
        } catch {
            next(new Error("Authentication error"));
        }
    });

A Note on connectionStateRecovery

Back in the previous post, we saw the skipMiddlewares option under connectionStateRecovery. Now this makes more sense in context: if skipMiddlewares is true, a reconnecting client skips this authentication middleware entirely during recovery. This is convenient, since a user's brief WiFi drop won't force a full re-authentication, but it also means a user whose token expired, or whose access was revoked, during that disconnection window could still reconnect without being re-checked. For anything security-sensitive, consider setting skipMiddlewares: false, accepting the small cost of re-running authentication on every reconnect.

Applying This to Our Chat App

For our course project, add the middleware shown above to server.ts, right before the io.on("connection", ...) block. On the client, replace the random username we generated earlier with a real token obtained from wherever your app handles login, and pass it through the auth option when creating the socket in src/lib/socket.ts.

Summary

  • io.use() registers middleware that runs before a connection is accepted, and can reject it using next(new Error(...))
  • The client sends credentials through the auth option during connection setup, accessible on the server as socket.handshake.auth
  • JWT verification inside this middleware is the standard way to authenticate a Socket.IO connection
  • Verified user data should be stored on socket.data, so event handlers can trust it instead of relying on client-supplied fields
  • Namespaces can have their own separate authentication middleware, useful for admin-only or role-restricted sections
  • skipMiddlewares in connection state recovery is a trade-off between convenience and strict re-authentication on every reconnect

In the next post, we cover TypeScript best practices for Socket.IO in more depth, tightening up typed events and payloads across the whole project.

No comments:

Post a Comment

PHASE 5 — Topic 20: Error Handling and Validating Data Sent Through Sockets

We ended the last post with a warning: TypeScript types do not protect you at runtime. This post covers the actual safety net, validating in...