DEV Community

Cover image for The WebSocket Auth Problem: Cookies vs. Bearer Tokens
Nikhil Sharma
Nikhil Sharma

Posted on

The WebSocket Auth Problem: Cookies vs. Bearer Tokens

If you've ever built a real-time web app using WebSockets, you've probably hit this exact wall - how do I do the auth.

On the HTTP side, the answer is pretty simple. You either use a cookie, which has so many npm packages, or you attach an Authorization: Bearer <token> header to your requests, which is a simple JWT signed string. However, things get a bit weird on the WebSocket side of things.

The fundamental issue stems from how WebSocket connections are established. Before a WebSocket connection becomes a continuous bidirectional channel of data, it starts off as a simple HTTP GET request with an Upgrade header. This is known as the WebSocket Handshake.

Let's dive into why this handshake makes the seemingly simple task of authentication a surprisingly nuanced architectural decision.


Why Bearer Tokens are Clunky for WebSockets

If your application relies on Bearer tokens (usually stored in memory or local storage on the client side), you are used to attaching them via the Authorization header using an interceptor or a fetch wrapper.

// A typical HTTP request with a Bearer token
fetch('/api/data', {
  headers: {
    'Authorization': `Bearer ${myToken}`
  }
});
Enter fullscreen mode Exit fullscreen mode

However, the native browser WebSocket API does not support custom headers.

// This is all we get.
const ws = new WebSocket('wss://api.myapp.com/socket'); 
Enter fullscreen mode Exit fullscreen mode

Since you can't pass the token in an Authorization header during the initial HTTP Upgrade request, developers are forced into clunky workarounds:

  1. Query Parameters: Passing the token in the URL (wss://api.myapp.com/socket?token=ey...). This is generally considered a bad practice because URLs (and the sensitive tokens within them) end up in server logs, browser histories, and proxy logs.
  2. First Message Auth: Opening the WebSocket connection unauthenticated, and then requiring the client to immediately send an authentication message containing the token before the server accepts any further messages. While secure, this adds latency and complexity to the connection lifecycle logic. You have to write boilerplate to handle connections that open but never authenticate.
  3. Subprotocols: A hacky but working method where the token is passed as a WebSocket subprotocol (new WebSocket('wss://...', ['access_token', token])).

None of these feel particularly clean or idiomatic.


Why Cookies Work Naturally

This brings us to cookies, the method that I chose for Relay! Cookies are managed by the browser. Whenever the browser makes an HTTP request to a domain, it automatically attaches the cookies associated with that domain.

Because the WebSocket handshake is just a standard HTTP GET request, the browser automatically includes your cookies in the first request itself!

If your authentication system sets an HTTP-only cookie containing your JWT or session ID when the user logs in, you literally don't have to change any client-side code to authenticate your WebSockets. The browser handles it natively and securely.

// The browser automatically attaches the JWT auth cookie!
const ws = new WebSocket('wss://api.myapp.com/socket');
Enter fullscreen mode Exit fullscreen mode

The Backend Implementation: Parsing the Cookie

While the client side becomes trivial, the backend need manual cookie parsing. When your WebSocket server receives the upgrade request, it hands you a raw Node.js IncomingMessage object.

Unlike Express requests which come with the cookies objects provided by middleware, the IncomingMessage just gives you the raw cookie header string. You have to parse it yourself before accepting the WebSocket connection.

Below is how I did the cookie parsing in Relay!

1. Extracting and Verifying the Token

First, we need a utility function to take the raw IncomingMessage, parse the cookie string, and verify our JWT.

import cookie from "cookie";
import jwt from "jsonwebtoken";
import type { IncomingMessage } from "http";
import { jwtPayloadSchema } from "../../http/schemas/auth.schema.js";

// Extract user ID from the raw HTTP upgrade request
export function extractUserId(req: IncomingMessage): string | null {
  // Parse the raw cookie header string
  const cookies = cookie.parse(req.headers.cookie ?? "");
  const token = cookies.token;

  if (!token) return null;

  try {
    // Verify the JWT signature
    const decoded = jwt.verify(token, process.env.JWT_SECRET as string);

    // Validate the payload shape
    const parsed = jwtPayloadSchema.safeParse(decoded);
    if (!parsed.success) {
      return null;
    }

    // Return the authenticated User ID
    return parsed.data.id;
  } catch {
    return null; // Token is invalid or expired
  }
}
Enter fullscreen mode Exit fullscreen mode

2. The main Socket handling code

Now here, I integrate this utility into my WebSocket server initialisation. I intercept the connection, attempt to extract the user ID, and immediately close the connection with a 4001 Unauthorized status if the authentication fails.

import { WebSocketServer, type WebSocket } from "ws";
import type { Server as HttpServer } from "http";
import { extractUserId } from "./handlers/message.handler.js";
import { sockets } from "./store.js";

export function initWebSocketServer(server: HttpServer) {
  const wss = new WebSocketServer({ server });

  wss.on("connection", (ws: WebSocket, request: IncomingMessage) => {
    ws.on("error", console.error);

    // 1. Authenticate immediately using the parsed cookie
    const id = extractUserId(request);

    // 2. Reject unauthenticated connections at the handshake level
    if (!id) {
      ws.close(4001, "Unauthorized");
      return;
    }

    // 3. Connection is fully authenticated!
    // We can safely associate this socket with the user
    sockets.addUser(id, ws);

    // Proceed with authorized application logic...
    console.log(`User ${id} connected successfully.`);

    ws.on("message", async (data) => {
      // ... handle incoming messages knowing the sender's true identity
    });

    ws.on("close", () => {
      sockets.removeUserSocket(id, ws);
    });
  });
}
Enter fullscreen mode Exit fullscreen mode

Wrapping up

When building real-time applications, relying on HTTP-only cookies for authentication eliminates the friction of securing WebSockets. By leaning on the browser's native cookie management, we avoid leaking tokens in URLs and bypass the complexity of first-message authentication handshakes. It requires a tiny bit of manual header parsing on the backend, but the resulting client-side simplicity and security posture are well worth the trade-off.

For those of you who would like to go through the codebase, you can do so here.
I'll be back with more next week. Until then, stay consistent!

Top comments (0)