Skip to content

Authentication ​

The WebSocket client supports flexible token-based authentication with automatic refresh on reconnection.

Quick Start ​

typescript
import { wsClient } from "@ws-kit/client/zod"; // ✅ Typed client

const client = wsClient({
  url: "wss://api.example.com/ws",
  auth: {
    getToken: () => localStorage.getItem("access_token"),
    attach: "query", // or "protocol"
  },
});

await client.connect();

Auth Configuration ​

Basic Options ​

typescript
auth?: {
  getToken?: () => string | null | undefined | Promise<string | null | undefined>;
  attach?: "query" | "protocol";  // default: "query"
  queryParam?: string;             // default: "access_token"
  protocolPrefix?: string;         // default: "bearer."
  protocolPosition?: "append" | "prepend";  // default: "append"
}

getToken - Token retrieval function

  • Called once per (re)connect
  • Supports sync or async
  • Return null/undefined to skip auth

attach - How to send token

  • "query" (default): Append to URL query string
  • "protocol": Send via WebSocket subprotocol

queryParam - Query parameter name (default: "access_token")

protocolPrefix - Protocol prefix (default: "bearer.")

protocolPosition - Where to place auth protocol (default: "append")

Attach Methods ​

Query String (Default) ​

Append token as URL query parameter.

typescript
const client = wsClient({
  url: "wss://api.example.com/ws",
  auth: {
    getToken: () => "abc123",
    attach: "query", // default
    queryParam: "access_token", // default
  },
});

// Connects to: wss://api.example.com/ws?access_token=abc123

Security note: Tokens in URLs may be logged by browsers, proxies, or servers. For sensitive applications, prefer attach: "protocol" instead. See Security Best Practices below.

WebSocket Protocol ​

Send token via Sec-WebSocket-Protocol header instead of URL query parameters.

typescript
const client = wsClient({
  url: "wss://api.example.com/ws",
  protocols: "chat-v2", // Your app protocol
  auth: {
    getToken: () => "abc123",
    attach: "protocol",
    protocolPrefix: "bearer.", // default
    protocolPosition: "append", // "append" (default) or "prepend"
  },
});

// Append (default): protocols sent as ["chat-v2", "bearer.abc123"]
// Prepend: protocols sent as ["bearer.abc123", "chat-v2"]

When combining app-defined protocols with auth tokens, specify protocolPosition:

  • append (default): Token protocol added after user protocols
  • prepend: Token protocol added before user protocols (some servers require auth first)

Edge cases:

  • No user protocols: Auth protocol appears alone
  • Token is null: Only user protocols sent
  • Duplicate protocols: First occurrence kept

Security note: More secure than query parameters (tokens not logged in URLs). Always use wss:// (TLS) for production. See Security Best Practices below.

Token Refresh ​

The getToken() function is called on every connection attempt, enabling automatic token refresh:

typescript
let cachedToken: string | null = null;

const client = wsClient({
  url: "wss://api.example.com/ws",
  reconnect: { enabled: true },
  auth: {
    getToken: async () => {
      // Sync: return cached value
      if (cachedToken) return cachedToken;

      // Async: fetch fresh token from auth service
      const response = await fetch("/api/auth/token");
      const { token } = await response.json();

      // Storage: optionally save to localStorage
      localStorage.setItem("access_token", token);
      cachedToken = token;
      return token;
    },
    attach: "query",
  },
});

// On logout: clear token and close connection
function logout() {
  localStorage.removeItem("access_token");
  cachedToken = null;
  client.close();
}

Server Protocol Selection ​

The server selects ONE protocol from the client's list. Check which was selected:

typescript
client.onState((state) => {
  if (state === "open") {
    console.log("Selected protocol:", client.protocol);

    // Validate server selected expected protocol
    if (client.protocol !== "chat-v2" && client.protocol !== "") {
      client.close({ code: 1002, reason: "Unsupported protocol" });
    }
  }
});

Possible values:

  • client.protocol === "bearer.abc123" - Server selected token protocol
  • client.protocol === "chat-v2" - Server selected app protocol
  • client.protocol === "" - Server accepted connection but selected no protocol

Validation ​

The client validates protocolPrefix during client creation if protocol auth is configured.

Valid prefixes (any characters except spaces and commas):

typescript
"bearer.";
"auth-";
"token_";
"bearer:"; // Also valid
"token/v2"; // Also valid

Invalid prefixes (must not contain spaces or commas):

typescript
"bearer "; // ❌ space
"auth,"; // ❌ comma
"my token"; // ❌ space

Error handling:

typescript
try {
  const client = wsClient({
    url: "wss://api.example.com",
    auth: {
      getToken: () => "token",
      attach: "protocol",
      protocolPrefix: "bearer ", // Invalid!
    },
  }); // Error thrown here, not at connect()
} catch (err) {
  // TypeError: Invalid protocolPrefix: "bearer " (must not contain spaces/commas)
}

Server Setup ​

Your server must be configured to accept the auth protocol.

Bun Adapter ​

The authenticate function is called during WebSocket upgrade. Return connection data to accept, return undefined to accept without custom data, or throw an error to reject:

typescript
import { z, message, createRouter } from "@ws-kit/zod";
import { serve } from "@ws-kit/bun";

type AppData = { userId?: string };

const router = createRouter<AppData>();

// Define message schema
const SomeMessage = message("SOME_MESSAGE", { text: z.string() });

// Register message handler
router.on(SomeMessage, (ctx) => {
  console.log("User:", ctx.data?.userId);
});

// User-provided token validation function (implement based on your auth scheme)
function validateToken(token: string): boolean {
  // Verify JWT signature, check expiry, etc.
  return true;
}

function getUserIdFromToken(token: string): string {
  // Extract user ID from token (e.g., JWT payload claim)
  return "user-123";
}

serve(router, {
  port: 3000,
  authenticate(req) {
    // Extract token from query string
    const url = new URL(req.url);
    const token = url.searchParams.get("access_token");

    if (!token) {
      return undefined; // Accept: no custom data attached
    }

    if (!validateToken(token)) {
      throw new Error("Unauthorized"); // Reject: HTTP 500 response, client receives connection error
    }

    const userId = getUserIdFromToken(token);
    return { userId }; // Accept: attach custom data to connection
  },
});

Authentication Flow:

  • authenticate() is called during the WebSocket upgrade (before the connection is established)
  • Return custom data to attach it to ctx.data (merged with auto-generated clientId)
  • Return undefined to accept the connection without custom data
  • Throw an error to reject the connection (HTTP 500 response in Bun adapter; client receives connection error)
  • The function can be async (return Promise<TData>) for database lookups or API calls

Connection Identity:

  • clientId is automatically generated by the server (UUID v7, time-ordered)
  • You cannot override clientId - it's always server-generated for security
  • Access via ctx.clientId in all handlers and hooks
  • Custom data returned from authenticate() is merged with { clientId }

Protocol-Based Auth ​

typescript
import { createRouter } from "@ws-kit/zod";
import { serve } from "@ws-kit/bun";

type AppData = { userId?: string };

const router = createRouter<AppData>();

// User-provided helper functions (implement based on your auth scheme)
function validateToken(token: string): boolean {
  return true;
}

function getUserIdFromToken(token: string): string {
  return "user-123";
}

serve(router, {
  port: 3000,
  authenticate(req) {
    // Extract token from WebSocket protocol header
    const protocols = req.headers.get("sec-websocket-protocol");
    const token = protocols
      ?.split(",")
      .map((p) => p.trim())
      .find((p) => p.startsWith("bearer."))
      ?.slice(7); // Remove "bearer." prefix

    if (!token) {
      return undefined; // Accept connection without auth
    }

    if (!validateToken(token)) {
      throw new Error("Unauthorized"); // Reject connection
    }

    const userId = getUserIdFromToken(token);
    return { userId }; // Accept with custom data
  },
});

Server Protocol Selection:

The server automatically handles protocol selection during the WebSocket upgrade. Bun's server.upgrade() manages the Sec-WebSocket-Protocol response header based on the protocols sent by the client.

Key Points:

  • The authenticate() function runs before protocol selection
  • Extract auth token from the sec-websocket-protocol header
  • The server may select any protocol from the client's list (or none)
  • Check client.protocol on the client side to see which protocol was selected
  • Protocol-based auth is more secure than query parameters (not logged in URLs)

Async Authentication ​

For database lookups or external API calls, use async authentication:

typescript
import { createRouter } from "@ws-kit/zod";
import { serve } from "@ws-kit/bun";

type AppData = { userId: string; email: string; roles: string[] };

const router = createRouter<AppData>();

// User-provided database access (adapt to your ORM/driver)
const db = {
  users: {
    async findById(userId: string) {
      // Fetch from database
      return { id: userId, email: "user@example.com", roles: ["user"] };
    },
  },
};

// User-provided JWT verification (use a library like `jose`)
async function verifyJWT(token: string) {
  // Verify signature and decode JWT payload
  return { userId: "user-123", iat: Date.now() };
}

serve(router, {
  port: 3000,
  async authenticate(req) {
    // Extract token from Authorization header
    const authHeader = req.headers.get("authorization");
    const token = authHeader?.replace("Bearer ", "");

    if (!token) {
      return undefined; // Anonymous connection
    }

    try {
      // Async token verification (e.g., JWT validation with database lookup)
      const payload = await verifyJWT(token);

      // Fetch user data from database
      const user = await db.users.findById(payload.userId);

      if (!user) {
        throw new Error("User not found");
      }

      // Return authenticated user data
      return {
        userId: user.id,
        email: user.email,
        roles: user.roles,
      };
    } catch (error) {
      // Reject connection on auth failure
      throw new Error("Invalid token");
    }
  },
});

Benefits of Async Auth:

  • Database lookups to verify tokens
  • External API calls for OAuth validation
  • Complex permission checks before connection
  • Rate limiting based on user identity

Error Handling:

  • Throwing an error rejects the connection (HTTP 500 response in Bun adapter; client receives connection error)
  • Returning undefined accepts anonymous connections
  • Authentication runs once per connection (not per message)

Security Best Practices ​

Use TLS (wss://) ​

typescript
// ✅ Secure
const client = wsClient({
  url: "wss://api.example.com/ws",
  auth: { getToken: () => token },
});

// ❌ Insecure (development only)
const client = wsClient({
  url: "ws://localhost:3000/ws",
  auth: { getToken: () => token },
});

Short-Lived Tokens ​

typescript
// ✅ Token expires in 15 minutes
const client = wsClient({
  url: "wss://api.example.com/ws",
  reconnect: { enabled: true },
  auth: {
    getToken: async () => {
      const { token, expiresAt } = await getShortLivedToken();
      return token;
    },
  },
});

// Server validates expiry on each message

Avoid Logging Tokens ​

typescript
// ❌ Don't log tokens
console.log("Token:", token);

// ✅ Log safely
console.log("Token length:", token?.length);
console.log("Has token:", !!token);

Custom Auth Mechanisms ​

Browser Limitation: The WebSocket API doesn't allow setting arbitrary HTTP headers (e.g., Authorization, custom headers) from JavaScript. Use one of the documented auth methods above instead.

For special cases, you can provide a custom WebSocket factory. This is useful for:

  • Testing (injecting a fake WebSocket)
  • Non-browser environments (Node.js, Bun) with custom header support
  • Customizing the WebSocket constructor call
typescript
const client = wsClient({
  url: "wss://api.example.com/ws",
  auth: { getToken: () => "token", attach: "protocol" },
  protocols: "chat-v2",
  wsFactory: (url, protocols) => {
    // Pass protocols through so auth subprotocols and user protocols reach WebSocket
    const ws = new WebSocket(url, protocols, {
      headers: { Authorization: "Bearer token" }, // Node.js/Bun only
    });
    return ws;
  },
});

For cookie-based authentication, the browser sends cookies automatically without any code:

typescript
// Cookies are sent by browser automatically (same-site requests)
const client = wsClient({
  url: "wss://api.example.com/ws",
  // No auth config needed—cookies attached by browser
});