Authenticating Socket.io Connections with JWT

An unauthenticated Socket.io connection can listen to any room it joins and emit any event โ€” there is nothing stopping a user from joining any post room or emitting fake events unless you verify their identity. Just as Express protect middleware verifies JWTs on HTTP requests, Socket.io middleware verifies them on socket connections. In this lesson you will send the JWT in the socket auth handshake from React, write Socket.io middleware that calls jwt.verify() and attaches the decoded user to socket.data, and restrict room-join and event-emit operations to authenticated sockets.

The Socket Auth Handshake

React client โ€” sends token on connection:
  io(SOCKET_URL, {
    auth: { token: localStorage.getItem('token') }
  })

Socket.io server โ€” middleware verifies token before connection:
  io.use((socket, next) => {
    const token = socket.handshake.auth.token;
    try {
      const decoded     = jwt.verify(token, JWT_SECRET);
      socket.data.user  = decoded; // attach user to socket
      next();                      // allow connection
    } catch (err) {
      next(new Error('Authentication failed')); // reject connection
    }
  });

After middleware:
  socket.data.user = { id: '64a1f2b3', role: 'user', ... }
  Use this in any socket event handler to identify the user
Note: Socket.io middleware runs once per connection โ€” when the client first connects. It does not run on every event. This is appropriate because the token is verified at connection time; if the token is valid, the socket is trusted for its lifetime. If you need per-event auth (e.g. the token expires during a long session), implement re-authentication by emitting a new token from the client and updating socket.data.user via a custom event, or disconnect expired sessions by checking token expiry in a long-lived timer.
Tip: For public-facing events (view counts, new comments that anyone can see), authentication is optional โ€” unauthenticated sockets can still join post rooms and receive events. Reserve authentication requirements for events that create data or access private information: posting a comment, accessing DM notifications, or emitting events that trigger database writes. Allow unauthenticated connections but gate specific events using if (!socket.data.user) return socket.emit('error', 'auth required').
Warning: Never trust socket.data.user from the client โ€” it is server-side data that you set after verifying the JWT, not something the client can modify. However, the token itself comes from the client and is verifiable only because it is signed by your JWT_SECRET. A token with an expired or invalid signature will be caught by jwt.verify() and the middleware will call next(error), rejecting the connection. Always use jwt.verify(), never jwt.decode(), in socket middleware.

Socket.io Auth Middleware โ€” Server

// server/src/middleware/socketAuth.js
const jwt  = require('jsonwebtoken');
const User = require('../models/User');

// โ”€โ”€ Full auth middleware โ€” fetches user from DB โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€
const socketAuthMiddleware = async (socket, next) => {
  try {
    const token = socket.handshake.auth.token;

    if (!token) {
      // Allow unauthenticated connections (for public events like view counts)
      socket.data.user = null;
      return next();
    }

    // Verify the JWT
    const decoded = jwt.verify(token, process.env.JWT_SECRET);

    // Fetch the current user from DB โ€” ensures account still exists
    const user = await User.findById(decoded.id).select('_id name role avatar');
    if (!user) {
      socket.data.user = null;
      return next();
    }

    socket.data.user = user; // attach to socket for use in all handlers
    next();

  } catch (err) {
    if (err.name === 'TokenExpiredError') {
      // Allow connection but mark as unauthenticated
      socket.data.user = null;
      next();
    } else {
      // Invalid token โ€” reject
      next(new Error('Authentication error'));
    }
  }
};

module.exports = socketAuthMiddleware;

// server/index.js โ€” apply middleware
const socketAuthMiddleware = require('./src/middleware/socketAuth');
io.use(socketAuthMiddleware);

Using socket.data.user in Event Handlers

// server/src/config/socket.js โ€” with auth-aware handlers
const setupSocketHandlers = (io) => {
  io.on('connection', (socket) => {
    const user = socket.data.user;

    console.log(`Socket connected: ${socket.id} โ€” User: ${user?.name || 'anonymous'}`);

    // โ”€โ”€ Join post room โ€” open to authenticated users only โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€
    socket.on('join-post', ({ postId }) => {
      if (!user) return socket.emit('error', { message: 'Authentication required to join rooms' });
      socket.join(`post:${postId}`);
      socket.emit('joined-post', { postId });
    });

    // โ”€โ”€ Typing indicator โ€” require auth and use server-side user name โ”€โ”€โ”€โ”€
    socket.on('typing', ({ postId, isTyping }) => {
      if (!user) return; // silently ignore unauthenticated typing events
      // Use server-side user.name โ€” client cannot spoof it
      socket.to(`post:${postId}`).emit('user-typing', {
        userName: user.name,
        isTyping,
      });
    });

    // โ”€โ”€ Post a like โ€” require auth โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€
    socket.on('like-post', async ({ postId }) => {
      if (!user) return socket.emit('error', { message: 'Login to like posts' });
      // Update DB and broadcast
      const post = await Post.findByIdAndUpdate(
        postId,
        { $addToSet: { likes: user._id } },
        { new: true }
      );
      io.to(`post:${postId}`).emit('like-update', { likeCount: post.likes.length });
    });

    socket.on('disconnect', () => {
      console.log(`Disconnected: ${socket.id}`);
    });
  });
};

module.exports = setupSocketHandlers;

React Client โ€” Sending Token in Auth Handshake

// src/hooks/useSocket.js โ€” with auth token
import { io }      from 'socket.io-client';
import { useAuth } from '@/context/AuthContext';

const SOCKET_URL = import.meta.env.VITE_API_URL?.replace('/api', '') || 'http://localhost:5000';

// โ”€โ”€ Token-aware socket factory โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€
let socketInstance = null;

const getSocket = (token) => {
  // Disconnect and reconnect if token changes (login/logout)
  if (socketInstance && socketInstance.auth?.token !== token) {
    socketInstance.disconnect();
    socketInstance = null;
  }

  if (!socketInstance) {
    socketInstance = io(SOCKET_URL, {
      auth: { token: token || '' }, // send token in handshake
      autoConnect:  true,
      reconnection: true,
    });
  }
  return socketInstance;
};

export function useSocket() {
  const { token } = useAuth(); // { token } exposed by AuthContext
  return getSocket(token);
}

// src/context/AuthContext.jsx โ€” expose token
const value = useMemo(() => ({
  user,
  token: localStorage.getItem('token'), // expose current token
  login,
  logout,
}), [user, login, logout]);

Handling Socket Auth Errors in React

// Listen for auth errors from the server
useEffect(() => {
  const socket = getSocket(token);

  socket.on('connect_error', (err) => {
    if (err.message === 'Authentication error') {
      console.error('Socket auth failed โ€” token may be invalid');
      // Optionally logout the user
    }
  });

  socket.on('error', ({ message }) => {
    console.warn('Socket error:', message);
  });

  return () => {
    socket.off('connect_error');
    socket.off('error');
  };
}, [token]);

Common Mistakes

Mistake 1 โ€” Using jwt.decode() instead of jwt.verify() in socket middleware

โŒ Wrong โ€” anyone can craft a socket with a fake decoded user ID:

const decoded = jwt.decode(token); // no signature check!
socket.data.user = { id: decoded.id }; // attacker sets id to admin's _id

โœ… Correct โ€” always verify the signature:

const decoded = jwt.verify(token, process.env.JWT_SECRET); // โœ“ signature verified

Mistake 2 โ€” Trusting user data from the client in events

โŒ Wrong โ€” using client-provided userName in events:

socket.on('typing', ({ postId, userName }) => {
  socket.to(`post:${postId}`).emit('user-typing', { userName }); // spoofable!
});

โœ… Correct โ€” use server-side socket.data.user:

socket.on('typing', ({ postId }) => {
  const userName = socket.data.user?.name || 'Anonymous'; // โœ“ server-side
  socket.to(`post:${postId}`).emit('user-typing', { userName });
});

Mistake 3 โ€” Not reconnecting the socket after login/logout

โŒ Wrong โ€” socket created before login, new token not sent after authentication:

// Socket created anonymously, user logs in, socket still has no token
const socket = io(SOCKET_URL, { auth: { token: '' } });
// After login: socket.auth.token is still '' โ€” server sees unauthenticated

โœ… Correct โ€” disconnect and reconnect with the new token after login:

// After login in AuthContext:
socket.disconnect();
socketInstance = null; // clear cached instance
// Next getSocket(token) call creates a new connection with the fresh token โœ“

Quick Reference

Task Code
Send token from React io(URL, { auth: { token } })
Read token on server socket.handshake.auth.token
Apply middleware io.use((socket, next) => { ... next() })
Attach user to socket socket.data.user = user
Read user in handler socket.data.user
Reject connection next(new Error('message'))
Gate an event if (!socket.data.user) return socket.emit('error', ...)

🧠 Test Yourself

Your typing indicator socket event trusts the userName field sent by the client: socket.on('typing', ({ postId, userName }) => socket.to(...).emit('user-typing', { userName })). A malicious user sends { postId: '123', userName: 'Admin' }. What can they do?