There are few resources to explore when it comes to authenticating socket.io
connections. I recently finished a programming project that involved authenticating socket connections. During the project, I tried my best to search for resources that explained how to create an authentication system for socket.io
connections, but to no avail. They were either not useful for my project's use case or were unclear. With some effort, I was able to build a secure authentication system for the server so that every connecting client will be authenticated before a connection is established.
In this article, I will be taking you through the step-by-step process of building a secure socket.io
authentication system with JSON web tokens (JWT). The knowledge can also be transferred to other authentication libraries, like passport.js
or other programming languages' authentication libraries, as I will be using NodeJs.
Let's dive in!
Overview
I will be explaining every detail of the example codes to let everyone follow along regardless of the tech stacks they are familiar with.
However, we will build the authentication system using a MongoDB User
collection, an http
server, an Express app
, and a socket.io
server instance. Additionally, I will provide a sample client-side code to demonstrate its use case.
The User
collection is used to store and retrieve the users' credentials.
The http
server is used to listen to HTTP and socket connection requests.
The express app
is used to set up function handlers that handle the registration and log-in authentication endpoints. A response containing a JSON web token (JWT) is expected on successful authentication.
The socket.io
server instance is responsible for managing socket connection events. A middleware function is utilized to validate the JWT sent by the client, ensuring that only authenticated users make socket connection requests.
The client-side code is used to make HTTP requests to the authentication endpoints and stores the JWT response, which is then used to make web socket connection requests to the socket.io
server instance.
The Authentication System
In this section, we build the authentication system. From the database model to the socket.io
server instance authentication middleware setup.
Database Model
Let's start by creating the User
model schema.
const mongoose = require('mongoose');
const userSchema = new mongoose.Schema({
username: String,
email: String,
password: String,
});
const User = mongoose.model('User', userSchema);
module.exports = User;
We import the mongoose
module, which provides the functionality to define and interact with MongoDB schemas and models.
A userSchema
is created using the mongoose.Schema
constructor, specifying the structure and data types of the user object. The schema includes fields for username
, email
, and password
, each of type String
.
The userSchema
is then used to create a User
model. This model allows for interaction with the 'User' collection in the connected MongoDB database.
Finally, the User
model is exported to make it available for use in the authentication system.
Express Authentication Handlers
Here, we'll be creating the handler functions for the registration and log-in endpoints using Express.
First of all, let's import the necessary modules.
const express = require('express');
const bcrypt = require('bcrypt');
const jwt = require('jsonwebtoken');
const User = require("models/user.model");
In the code above, the express
, bcrypt
, and jsonwebtoken
module are all being assigned to their respective variables. Additionally, the User
database model is imported to enable the storage and verification of user information.
Next, we create the Express app
.
const app = express();
app.use(express.json());
// Register API endpoint
app.post('/auth/register', registerUser);
// Login API endpoint
app.post('/auth/login', loginUser);
We begin by invoking the express()
function provided by the express module, which allows us to register endpoints along with their respective handler functions. Additionally, we attach the express.json()
middleware to parse JSON request payloads. Two POST
methods are subsequently added to the Express app
, enabling it to handle client requests at the /auth/register
and /auth/login
endpoints. These requests are handled by the registerUser
and loginUser
handler functions, respectively.
Next, we proceed to develop the logic for the registerUser
handler function.
// User Registration Handler Function
async function registerUser(req, res) {
try {
const { username, email, password } = req.body;
// Check if the username or email already exists
const existingUser = await User.findOne().or([{ username }, { email }]);
if (existingUser) {
return res.status(400).json({ message: 'Username or email already exists' });
}
// Hash the password
const hashedPassword = await bcrypt.hash(password, 10);
// Create a new user
const newUser = new User({
username,
email,
password: hashedPassword,
});
// Save the user to the database
await newUser.save();
res.status(201).json({ message: 'Registration successful' });
} catch (error) {
console.error('Registration error', error);
res.status(500).json({ message: 'Registration error' });
}
}
We begin by destructuring the username
, email
, and password
from the req.body
object, which contains the request body sent by the client. Next, we perform a check for the uniqueness of the email
and username
by searching for them in the database. If either of them is found, a 400
bad request response is returned. Otherwise, the function proceeds to hash the password using the bcrypt.hash()
function provided by the bcrypt module. The hashed password, along with the destructured username
and email
properties, are then saved to the database for persistence. In the absence of errors, a 201
created response is returned. However, if any error occurs during the execution of the handler function, a 500
internal server error response is returned.
Next, we proceed to develop the logic for the loginUser
handler function.
// ...
// User Login Handler Function
async function loginUser(req, res) {
try {
const { username, password } = req.body;
// Check if the username exists
const user = await User.findOne({ username });
if (!user) {
return res.status(400).json({ message: 'Invalid username or password' });
}
// Compare the password
const isPasswordValid = await bcrypt.compare(password, user.password);
if (!isPasswordValid) {
return res.status(400).json({ message: 'Invalid username or password' });
}
// Generate a JWT
const token = jwt.sign({ userId: user._id }, process.env.SECRET_KEY);
res.json({ token, message: 'Login successful' });
} catch (error) {
console.error('Login error', error);
res.status(500).json({ message: 'Login error' });
}
}
// ...
We first verify if the destructured username
exists in the database. If it does not exist, a 400
bad request response is returned. Also, if the destructured password
does not match the user-stored password in the database, a 400
bad response is returned. However, if the passwords match, we proceed to generate a JWT token using the jsonwebtoken
module. This token is generated by setting the userId
key to the value of the _id
property of the User, which is automatically assigned by MongoDB to each saved user information. Assuming no errors occur, a response containing the generated token is returned.
Next, we export the Express app
.
// ...
module.exports = app;
The complete code for the authentication endpoints can be found below.
const express = require('express');
const bcrypt = require('bcrypt');
const jwt = require('jsonwebtoken');
const User = require("models/user.model");
const app = express();
app.use(express.json());
// Register API endpoint
app.post('/auth/register', registerUser);
// Login API endpoint
app.post('/auth/login', loginUser);
// User Registration Handler Function
async function registerUser(req, res) {
try {
const { username, email, password } = req.body;
// Check if the username or email already exists
const existingUser = await User.findOne().or([{ username }, { email }]);
if (existingUser) {
return res.status(400).json({ message: 'Username or email already exists' });
}
// Hash the password
const hashedPassword = await bcrypt.hash(password, 10);
// Create a new user
const newUser = new User({
username,
email,
password: hashedPassword,
});
// Save the user to the database
await newUser.save();
res.json({ message: 'Registration successful' });
} catch (error) {
console.error('Registration error', error);
res.status(500).json({ message: 'Registration error' });
}
}
// User Login Handler Function
async function loginUser(req, res) {
try {
const { username, password } = req.body;
// Check if the username exists
const user = await User.findOne({ username });
if (!user) {
return res.status(400).json({ message: 'Invalid username or password' });
}
// Compare the password
const isPasswordValid = await bcrypt.compare(password, user.password);
if (!isPasswordValid) {
return res.status(400).json({ message: 'Invalid username or password' });
}
// Generate a JWT
const token = jwt.sign({ userId: user._id }, process.env.SECRET_KEY);
res.json({ token, message: 'Login successful' });
} catch (error) {
console.error('Login error', error);
res.status(500).json({ message: 'Login error' });
}
}
module.exports = app;
Our Express app
is now ready to be used.
Socket Server Setup
Now, let's set up the http
and socket.io
server instances for authentication.
const http = require('http');
const ioServer = require('socket.io');
const app = require('./app');
We begin by importing the http
and socket.io
modules, as well as our Express app
.
Next, we proceed to create instances of the http
and socket.io
servers.
// Create server from express app
const server = http.createServer(app);
// Create the socket server instance
const io = ioServer(server);
We use the createServer()
function provided by the http
module to create an HTTP server instance. The Express app
is then passed to the function to handle requests to the authentication endpoints. The http
server is then used to create a socket.io
server instance.
Next, we proceed to add the authentication middleware.
io.use(async (socket, next) => {
try {
const token = socket.handshake.auth.token;
// Verify and decode the JWT
const decoded = jwt.verify(token, process.env.SECRET_KEY);
// Get the user information from the database
const user = await User.findById(decoded.userId);
if (!user) {
throw new Error('User not found');
}
// Attach the user object to the socket
socket.user = user;
next();
} catch (error) {
console.error('Authentication error', error);
next(new Error('Authentication error'));
}
});
io.on('connection', (socket) => {
// Handle Events after authentication
}
In the above code, when a client connects to the server, the middleware function io.use()
, provided by the socket.io
library, is invoked. Inside the function, we first retrieve the JWT token from the socket.handshake.auth.token
property sent by the client during the handshake (connection). It then verifies and decodes the token using a secret key stored in an environment variable.
If the token is valid, the middleware retrieves the user information from the database based on the user ID extracted from the token. If the user is found, the user object is attached to the socket for future reference.
If any errors occur during the authentication process, such as an invalid token or user not found, an error is thrown, and the middleware calls the next()
function with an error argument.
After successful authentication, the io.on('connection')
event handler is triggered, allowing for further event handling and communication with the authenticated user.
The authentication system is now complete. But to listen to connections, the code below can be added or customized to your liking.
server.listen(PORT, () => {
console.log(` Server started running at ${PORT}`);
});
Where PORT
is the preferred port to listen to the connections. Also, don't forget to connect to your MongoDB collection before starting the server.
The complete code for the server setup can be found below.
const http = require('http');
const ioServer = require('socket.io');
const app = require('./app');
// Create server from express app
const server = http.createServer(app);
// Create the socket server instance
const io = ioServer(server);
io.use(async (socket, next) => {
try {
const token = socket.handshake.auth.token;
// Verify and decode the JWT
const decoded = jwt.verify(token, process.env.SECRET_KEY);
// Get the user information from the database
const user = await User.findById(decoded.userId);
if (!user) {
throw new Error('User not found');
}
// Attach the user object to the socket
socket.user = user;
next();
} catch (error) {
console.error('Authentication error', error);
next(new Error('Authentication error'));
}
});
io.on('connection', (socket) => {
// Handle Events after authentication
}
server.listen(PORT, () => {
console.log(` Server started running at ${PORT}`);
});
Client-Side Connection
To demonstrate how the client-side can be set up to use the authentication system, we'll be using Axios to make requests to the authentication endpoints and use the retrieved token to connect to the socket.io
server instance.
Let's start by registering a user.
const axios = require('axios');
const io = require('socket.io-client');
const username = 'HayatsCodes';
const email = 'hayatscodes@gmail.com';
const password = 123456;
let token;
try {
const response = await axios.post(`http://localhost:${PORT}/auth/register`, {
username,
email,
password,
});
console.log(response.data.message); // Registration successful
} catch (error) {
console.error(error);
}
Firstly, the axios
and socket.io-client
libraries are imported.
Then, we are registering a user by making a POST
request to the /auth/register
endpoint of the server. The server URL is constructed using the PORT
variable, which should contain the port number.
The user's username
, email
, and password
are provided as the request payload. We use the await
keyword to make the request and store the response in the response
variable.
If the registration is successful, the messsage from the response is logged to the console.
If an error occurs during the registration process, the catch
block is executed, and the error is logged to the console using console.error
.
Now, let's sign in the registered user.
try {
const response = await axios.post(`http://localhost:${PORT}/auth/login`, {
username,
password,
});
token = response.data.token;
console.log(response.data.message); // login successful
} catch (error) {
console.error(error.response.data);
}
};
We're signing in the registered user by making a POST request to the /auth/login
endpoint of the server while including the username
and password
in the request payload.
If the login is successful, the token is extracted from the response.data.token
property, and the message
is logged to the console.
The token is then assigned to the token
variable for use in the socket.io
client connection.
If an error occurs during the registration process, the catch
block is executed, and the error is logged to the console using console.error
.
Now let's connect to the socket.io
server.
const client = io(`http://localhost:${PORT}`, {
auth: {
token
}
});
// handle events
client.on('connect', () => { console.log('connected!') });
// Additional event handling can follow
In the above code snippet, we're establishing a client-side connection to the socket.io
server using the io
function provided by the socket.io-client
library.
The io
function is called with the socket server URL and an object as an argument, which includes the auth
property. This property specifies the authentication token that will be sent to the server during the handshake process. The value of the earlier saved token
variable from the login request is provided as the token value.
Once the connection is established, the client.on('connect')
event handler is set up to listen for the 'connect' event. When the client successfully connects to the server, the callback function is executed, logging 'connected!' to the console.
Additional event handling and communication with the server can be added within the appropriate event handlers.
Conclusion
Building a secure authentication system for socket.io
connections can be a challenging task due to the limited resources available. However, by leveraging JSON web tokens (JWT), it is possible to create a robust authentication system. In this article, we have covered the step-by-step process of building such a system, including setting up the database model, creating authentication endpoints with Express, implementing authentication middleware with socket.io
, and demonstrating client-side connections using axios
and the socket.io-client
library. By following the provided code examples and explanations, developers can build their own secure authentication system for socket.io
connections, allowing only authenticated users to establish connections and interact with the server.
Top comments (4)
If you can avoid the JWT complexity, perhaps because you don't use a distributed or micro-service architecture, just sharing the express session/cookie with socket.io can be a very simple alternative. I was able to achieve this with npm express-socket.io-session.
I understand that simplicity is crucial in some scenarios. Thanks for the suggestion.
😎 cool
Wow. Senior Dev. It's great to see that you liked it. I was actually writing this when you posted on LinkedIn about "Serving HTTP and Web Socket Connections on a Single Port with Secure Handshake!". I was like, "Should I continue this article?" because I really felt like a beginner in socketIO.