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?.tokenreads the token the client sentjwt.verify()throws if the token is invalid or expired, which we catch and reject withnext(new Error(...))- On success, we store the decoded user ID on
socket.data, this is exactly theSocketDatainterface we defined back in Phase 2, so this assignment is fully typed - Since
socket.datapersists for the lifetime of the connection, every event handler later can accesssocket.data.userIdwithout 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 usingnext(new Error(...))- The client sends credentials through the
authoption during connection setup, accessible on the server assocket.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
skipMiddlewaresin 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