← Back to DevBytes

Socket.io Authentication: JWT, Sessions, and OAuth Integration

Introduction to Socket.io Authentication

Socket.io is a popular library that enables real-time, bidirectional communication between web clients and servers. While it makes building chat applications, live dashboards, and collaborative tools straightforward, securing those connections is critical. By default, Socket.io connections are open to anyone who knows your endpoint, which means authentication must be implemented explicitly.

Authentication in Socket.io differs from traditional HTTP authentication because WebSockets maintain a persistent connection. You cannot simply rely on HTTP middleware alone — you need strategies that work within the connection lifecycle. This tutorial covers three widely used approaches: JWT (JSON Web Tokens), Session-based authentication, and OAuth integration. Each has its own trade-offs, and understanding when to use each will make your real-time applications both secure and maintainable.

Why Authentication Matters for WebSockets

Unlike REST APIs, where each request can be independently validated, WebSocket connections are long-lived. A single unauthenticated connection can subscribe to channels, receive sensitive data, and impersonate users for the duration of the session. Without proper authentication, attackers can:

Implementing authentication at the connection level — and re-verifying on reconnection — is essential for any production-grade real-time application.

Project Setup

Before diving into authentication strategies, let's set up a basic Socket.io server. Create a new project directory and install the necessary dependencies:

mkdir socketio-auth-tutorial
cd socketio-auth-tutorial
npm init -y
npm install express socket.io jsonwebtoken cookie-parser express-session
npm install --save-dev nodemon

Create a basic server file server.js that we will build upon throughout this tutorial:

const express = require("express");
const http = require("http");
const { Server } = require("socket.io");

const app = express();
const server = http.createServer(app);
const io = new Server(server, {
  cors: {
    origin: "http://localhost:3000",
    credentials: true,
  },
});

app.get("/", (req, res) => {
  res.json({ message: "Socket.io Auth Tutorial API" });
});

io.on("connection", (socket) => {
  console.log("New client connected:", socket.id);
  socket.on("disconnect", () => {
    console.log("Client disconnected:", socket.id);
  });
});

server.listen(4000, () => {
  console.log("Server running on port 4000");
});

This gives us a foundation. Now let's add authentication layers.

JWT Authentication with Socket.io

JWT is the most common authentication method for Socket.io. Tokens are stateless, self-contained, and easy to pass during the initial handshake. The typical flow is: the client authenticates via a REST endpoint, receives a JWT, and then sends that token when establishing the WebSocket connection.

Generating JWTs on Login

First, create a login route that issues JWTs. Add this to your server.js:

const jwt = require("jsonwebtoken");

const JWT_SECRET = "your-super-secret-key-change-in-production";

// Mock user database
const users = [
  { id: 1, username: "alice", password: "password123", role: "admin" },
  { id: 2, username: "bob", password: "password456", role: "user" },
];

app.use(express.json());

app.post("/login", (req, res) => {
  const { username, password } = req.body;
  const user = users.find(
    (u) => u.username === username && u.password === password
  );

  if (!user) {
    return res.status(401).json({ error: "Invalid credentials" });
  }

  const token = jwt.sign(
    { id: user.id, username: user.username, role: user.role },
    JWT_SECRET,
    { expiresIn: "1h" }
  );

  res.json({ token, user: { id: user.id, username: user.username, role: user.role } });
});

Verifying JWTs on Connection

Socket.io provides middleware that runs before the connection event fires. This is the ideal place to verify the JWT. There are two common ways to pass the token: as a query parameter or in the auth object. The auth object is preferred because it does not appear in server logs or URLs.

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

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

  jwt.verify(token, JWT_SECRET, (err, decoded) => {
    if (err) {
      return next(new Error("Invalid or expired token"));
    }

    // Attach user data to the socket for later use
    socket.user = decoded;
    next();
  });
});

io.on("connection", (socket) => {
  console.log(`User ${socket.user.username} connected with socket ${socket.id}`);

  // Join a personal room for targeted messages
  socket.join(`user:${socket.user.id}`);

  socket.on("message", (data) => {
    // Now you can trust socket.user
    io.emit("message", {
      from: socket.user.username,
      text: data.text,
      timestamp: new Date(),
    });
  });

  socket.on("disconnect", () => {
    console.log(`User ${socket.user.username} disconnected`);
  });
});

Handling Authentication Errors on the Client

On the client side, you need to pass the token and handle connection errors gracefully. Here is a complete client example:

<script src="/socket.io/socket.io.js"></script>
<script>
  let token = localStorage.getItem("jwtToken");

  async function login(username, password) {
    const res = await fetch("http://localhost:4000/login", {
      method: "POST",
      headers: { "Content-Type": "application/json" },
      body: JSON.stringify({ username, password }),
    });
    const data = await res.json();
    if (res.ok) {
      token = data.token;
      localStorage.setItem("jwtToken", token);
      connectSocket();
    }
  }

  function connectSocket() {
    const socket = io("http://localhost:4000", {
      auth: { token },
    });

    socket.on("connect", () => {
      console.log("Connected:", socket.id);
    });

    socket.on("connect_error", (err) => {
      if (err.message === "Invalid or expired token") {
        // Token expired — redirect to login
        localStorage.removeItem("jwtToken");
        window.location.href = "/login";
      }
      console.error("Connection error:", err.message);
    });

    socket.on("message", (msg) => {
      console.log("New message:", msg);
    });

    return socket;
  }

  if (token) {
    connectSocket();
  }
</script>

Refreshing Tokens

JWTs expire, and when they do, the WebSocket connection will be rejected on reconnection. A robust implementation uses refresh tokens. When the client detects an expired token error, it requests a new access token using the refresh token before reconnecting:

async function refreshToken() {
  const refreshToken = localStorage.getItem("refreshToken");
  const res = await fetch("http://localhost:4000/refresh", {
    method: "POST",
    headers: { "Content-Type": "application/json" },
    body: JSON.stringify({ refreshToken }),
  });
  const data = await res.json();
  if (res.ok) {
    localStorage.setItem("jwtToken", data.token);
    return data.token;
  }
  throw new Error("Refresh failed");
}

// In the connect_error handler:
socket.on("connect_error", async (err) => {
  if (err.message.includes("expired")) {
    try {
      const newToken = await refreshToken();
      socket.auth = { token: newToken };
      socket.connect();
    } catch (e) {
      window.location.href = "/login";
    }
  }
});

Session-Based Authentication

Session-based authentication relies on server-side session storage and a session cookie sent with the HTTP handshake. This approach is ideal if your application already uses session-based auth for its REST API and you want to share that same session with Socket.io. There is no need to manage tokens separately — the cookie does the work.

Configuring Express Sessions

Set up express-session with a store. For production, use a store like Redis rather than the default in-memory store:

const session = require("express-session");
const cookieParser = require("cookie-parser");

const sessionMiddleware = session({
  secret: "session-secret-change-in-production",
  resave: false,
  saveUninitialized: false,
  cookie: {
    httpOnly: true,
    secure: false, // Set to true in production with HTTPS
    sameSite: "lax",
    maxAge: 24 * 60 * 60 * 1000, // 24 hours
  },
});

app.use(cookieParser());
app.use(sessionMiddleware);

// Login route that creates a session
app.post("/session-login", (req, res) => {
  const { username, password } = req.body;
  const user = users.find(
    (u) => u.username === username && u.password === password
  );

  if (!user) {
    return res.status(401).json({ error: "Invalid credentials" });
  }

  req.session.user = {
    id: user.id,
    username: user.username,
    role: user.role,
  };

  res.json({ message: "Logged in", user: req.session.user });
});

app.post("/session-logout", (req, res) => {
  req.session.destroy((err) => {
    if (err) return res.status(500).json({ error: "Logout failed" });
    res.clearCookie("connect.sid");
    res.json({ message: "Logged out" });
  });
});

Sharing Sessions with Socket.io

The key to session-based Socket.io auth is sharing the express-session middleware with Socket.io. You do this by manually applying the session middleware to the socket handshake:

// Wrap the session middleware for Socket.io
const wrap = (middleware) => (socket, next) =>
  middleware(socket.request, {}, next);

io.use(wrap(cookieParser()));
io.use(wrap(sessionMiddleware));

io.use((socket, next) => {
  const session = socket.request.session;
  if (session && session.user) {
    socket.user = session.user;
    next();
  } else {
    next(new Error("Unauthorized — no valid session"));
  }
});

io.on("connection", (socket) => {
  console.log(`Session user ${socket.user.username} connected`);

  socket.on("privateMessage", (data) => {
    // Send only to the recipient's personal room
    io.to(`user:${data.toUserId}`).emit("privateMessage", {
      from: socket.user.username,
      text: data.text,
    });
  });
});

Client-Side Session Connection

With sessions, the client does not need to pass any token. The browser automatically includes the session cookie in the WebSocket handshake request, provided withCredentials is enabled:

const socket = io("http://localhost:4000", {
  withCredentials: true,
});

socket.on("connect", () => {
  console.log("Connected via session:", socket.id);
});

socket.on("connect_error", (err) => {
  console.error("Session auth failed:", err.message);
  // Redirect to login page
  window.location.href = "/login";
});

Make sure your CORS configuration on both Express and Socket.io includes credentials: true and a specific origin (not a wildcard) for cookies to be sent cross-origin.

OAuth Integration with Socket.io

OAuth allows users to authenticate using third-party providers like Google, GitHub, or Facebook. The challenge with OAuth and Socket.io is that OAuth requires browser redirects, which cannot happen during a WebSocket handshake. The solution is to perform OAuth through your REST API first, then use either a JWT or session to authenticate the WebSocket connection.

Setting Up Passport with Google OAuth

Install Passport and the Google strategy:

npm install passport passport-google-oauth20

Configure the OAuth strategy and routes:

const passport = require("passport");
const GoogleStrategy = require("passport-google-oauth20").Strategy;

passport.use(
  new GoogleStrategy(
    {
      clientID: process.env.GOOGLE_CLIENT_ID,
      clientSecret: process.env.GOOGLE_CLIENT_SECRET,
      callbackURL: "http://localhost:4000/auth/google/callback",
    },
    (accessToken, refreshToken, profile, done) => {
      // Find or create user in your database
      let user = users.find((u) => u.username === profile.emails[0].value);
      if (!user) {
        user = {
          id: users.length + 1,
          username: profile.emails[0].value,
          name: profile.displayName,
          avatar: profile.photos[0].value,
          role: "user",
        };
        users.push(user);
      }
      return done(null, user);
    }
  )
);

app.use(passport.initialize());

// Redirect to Google
app.get(
  "/auth/google",
  passport.authenticate("google", { scope: ["profile", "email"] })
);

// Google callback
app.get(
  "/auth/google/callback",
  passport.authenticate("google", { session: false }),
  (req, res) => {
    // Generate a JWT from the OAuth user
    const token = jwt.sign(
      {
        id: req.user.id,
        username: req.user.username,
        name: req.user.name,
        avatar: req.user.avatar,
        role: req.user.role,
      },
      JWT_SECRET,
      { expiresIn: "1h" }
    );

    // Redirect to frontend with token as query param
    res.redirect(`http://localhost:3000/oauth-callback?token=${token}`);
  }
);

Handling the OAuth Callback on the Client

After Google redirects back to your frontend, the client extracts the token from the URL and uses it to establish the Socket.io connection:

// oauth-callback.html
<script>
  const params = new URLSearchParams(window.location.search);
  const token = params.get("token");

  if (token) {
    localStorage.setItem("jwtToken", token);
    // Clean up the URL
    window.history.replaceState({}, document.title, "/dashboard");

    // Connect to Socket.io with the OAuth-derived JWT
    const socket = io("http://localhost:4000", {
      auth: { token },
    });

    socket.on("connect", () => {
      console.log("OAuth user connected:", socket.id);
    });

    socket.on("connect_error", (err) => {
      console.error("OAuth socket error:", err.message);
      window.location.href = "/login";
    });
  } else {
    window.location.href = "/login";
  }
</script>

Because the OAuth flow produces a JWT, the Socket.io middleware we wrote earlier for JWT verification works without any changes. This is the power of decoupling OAuth (the identity provider) from Socket.io (the transport layer).

OAuth with Sessions Instead of JWT

If you prefer sessions over JWTs, you can combine Passport session serialization with the session-based Socket.io middleware from the previous section:

passport.serializeUser((user, done) => done(null, user.id));
passport.deserializeUser((id, done) => {
  const user = users.find((u) => u.id === id);
  done(null, user || null);
});

app.use(sessionMiddleware);
app.use(passport.session());

app.get(
  "/auth/google",
  passport.authenticate("google", { scope: ["profile", "email"] })
);

app.get(
  "/auth/google/callback",
  passport.authenticate("google"),
  (req, res) => {
    // req.session is now populated with the user
    res.redirect("http://localhost:3000/dashboard");
  }
);

The Socket.io session middleware from earlier will automatically pick up the Passport-serialized session, and no additional code is needed on the Socket.io side.

Room-Based Authorization

Authentication tells you who the user is. Authorization determines what they can do. A common pattern in Socket.io is to use rooms for authorization — only allowing users to join rooms they have permission to access.

io.on("connection", (socket) => {
  // Join a room only if the user has permission
  socket.on("joinRoom", (roomId, callback) => {
    // Check if user is allowed to join this room
    if (!userHasAccess(socket.user, roomId)) {
      return callback({ error: "Access denied" });
    }

    socket.join(roomId);
    callback({ success: true, room: roomId });
  });

  socket.on("roomMessage", ({ roomId, text }) => {
    // Verify the sender is actually in the room
    if (!socket.rooms.has(roomId)) {
      return socket.emit("error", "You are not in this room");
    }

    io.to(roomId).emit("roomMessage", {
      from: socket.user.username,
      text,
      timestamp: new Date(),
    });
  });
});

function userHasAccess(user, roomId) {
  // Example: admins can join any room, users can only join their own
  if (user.role === "admin") return true;
  return roomId === `user:${user.id}` || roomId.startsWith("public:");
}

Best Practices

Always Authenticate at the Middleware Level

Never rely on authenticating inside event handlers. By the time an event handler runs, the socket is already connected and could have sent multiple events. Use io.use() middleware to reject unauthenticated connections before the connection event fires.

Use the auth Object, Not Query Parameters

Passing tokens as query parameters (?token=...) is a common mistake. Query strings are logged by proxies, load balancers, and server access logs, exposing your tokens. Always use the auth option in the client connection:

// BAD — token exposed in logs and URLs
const socket = io("http://localhost:4000?token=" + token);

// GOOD — token sent in the auth handshake payload
const socket = io("http://localhost:4000", {
  auth: { token },
});

Implement Rate Limiting

Authentication alone does not prevent brute-force attacks or connection flooding. Add rate limiting to your login endpoints and consider limiting the number of concurrent connections per IP or per user:

const connectionCounts = new Map();

io.use((socket, next) => {
  const ip = socket.handshake.address;
  const count = connectionCounts.get(ip) || 0;

  if (count >= 10) {
    return next(new Error("Too many connections from this IP"));
  }

  connectionCounts.set(ip, count + 1);
  socket.on("disconnect", () => {
    const c = connectionCounts.get(ip) || 1;
    connectionCounts.set(ip, c - 1);
  });

  next();
});

Validate Input on Every Event

Even authenticated users can send malformed or malicious data. Validate every incoming event payload using a library like Joi or Zod:

const Joi = require("joi");

const messageSchema = Joi.object({
  text: Joi.string().max(500).required(),
  roomId: Joi.string().required(),
});

socket.on("roomMessage", (payload) => {
  const { error, value } = messageSchema.validate(payload);
  if (error) {
    return socket.emit("error", "Invalid message format");
  }
  // Proceed with validated value
});

Use Secure Cookies and HTTPS in Production

For session-based auth, always set secure: true on cookies when serving over HTTPS. This prevents the cookie from being transmitted over insecure connections. Also set httpOnly: true to prevent JavaScript access, mitigating XSS-based session theft.

Handle Reconnection Gracefully

Socket.io automatically reconnects when the connection drops. If the user's token has expired during the disconnection, the reconnection will fail. Implement a reconnection strategy that refreshes the token first:

socket.io.on("reconnect_attempt", () => {
  // Update the auth token before reconnecting
  const freshToken = getFreshToken(); // Your refresh logic
  socket.auth = { token: freshToken };
});

Log Authentication Events

Maintain audit logs of authentication successes and failures. This helps detect attacks and troubleshoot issues:

io.use((socket, next) => {
  const token = socket.handshake.auth.token;
  jwt.verify(token, JWT_SECRET, (err, decoded) => {
    if (err) {
      console.warn(`Auth failed from ${socket.handshake.address}: ${err.message}`);
      return next(new Error("Authentication failed"));
    }
    console.log(`Auth success: user ${decoded.username} from ${socket.handshake.address}`);
    socket.user = decoded;
    next();
  });
});

Choose the Right Strategy for Your App

Conclusion

Securing Socket.io connections is not optional — it is a fundamental requirement for any real-time application that handles user data. Whether you choose JWT for its stateless flexibility, session-based auth for its simplicity and server-side control, or OAuth for delegated third-party identity, the principles remain the same: authenticate at the middleware level before the connection is established, authorize actions based on the authenticated user, validate all incoming data, and handle token expiration and reconnection gracefully. By following the patterns and best practices outlined in this tutorial, you can build real-time features that are both powerful and secure, giving your users a seamless experience without compromising on safety. Remember that authentication is an ongoing concern — keep your dependencies updated, rotate your secrets, and regularly audit your auth flows as your application evolves.

— Ad —

Google AdSense will appear here after approval

← Back to all articles