esc

Type to search...

Authentication

Browser WebSockets do not support custom headers, so you cannot attach an Authorization: Bearer ... header the way you would with fetch. Four standard workarounds, ordered by how defensible each one is:

  • First message (recommended): open the socket, then send the token as the first frame. Tokens never appear in URLs, logs, or proxies, and the server can re-challenge mid-connection without a reconnect.
  • HTTP-only cookie: the browser sends the session cookie automatically on upgrade. Safest default when your server supports it.
  • Subprotocol: pass the token as a WebSocket subprotocol. Cleaner than the query string but requires server negotiation.
  • Query string: simplest. Visible in server and proxy logs. Use only with short-lived access tokens.

First-message auth is covered first below because it is the pattern most production apps converge on. The URL-based sections follow for smaller or internal apps where the extra round-trip is not worth it.

First-message authentication

Open the socket with no credentials, then send an auth frame before anything else. onReady fires on every (re)connect, after the library has replayed subscriptions, so it is the right seam for the first message:

manager.ts
type TClientMsg =
  | { type: "auth"; token: string }
  | { type: "message"; text: string };

type TServerMsg =
  | { type: "auth-ok"; userId: string }
  | { type: "auth-expired" }
  | { type: "unauthorized"; reason: string }
  | { type: "message"; text: string };

const manager = new WebSocketManager<TClientMsg, TServerMsg>({
  url: "wss://api.example.com/ws",
  serialize: (msg) => JSON.stringify(msg),
  deserialize: (raw) => JSON.parse(raw),

  async onReady() {
    const token = await auth.getValidAccessToken();
    manager.send({ data: { type: "auth", token } });
  },
});

Track the authed state so components can gate reads and writes until the server confirms:

AuthProvider.tsx
type TAuthState = { authed: boolean; userId: string | null };

const AuthContext = createContext<TAuthState>({ authed: false, userId: null });

export function AuthProvider({ children }: { children: React.ReactNode }) {
  const [state, setState] = useState<TAuthState>({ authed: false, userId: null });

  useSocketEvent(manager, "auth-ok", (msg) => {
    setState({ authed: true, userId: msg.userId });
  });

  useSocketEvent(manager, "auth-expired", () => {
    setState((prev) => ({ ...prev, authed: false }));
  });

  useSocketEvent(manager, "unauthorized", () => {
    setState({ authed: false, userId: null });
  });

  return <AuthContext.Provider value={state}>{children}</AuthContext.Provider>;
}

export function useAuth() {
  return useContext(AuthContext);
}

function ChatInput() {
  const { authed } = useAuth();
  const { send } = useSocketSend(manager);

  return (
    <input
      disabled={!authed}
      onKeyDown={(e) => {
        if (e.key === "Enter" && authed) {
          send({ type: "message", text: e.currentTarget.value });
        }
      }}
    />
  );
}

Server challenge / mid-session re-auth

When a server cannot pre-validate the session (for example because the access token expires mid-connection), it can ask the client to re-auth without closing the socket. Treat the challenge as just another typed server message:

ReauthBridge.tsx
type TServerMsg =
  | { type: "auth-required" }
  | { type: "auth-expired" }
  | { type: "auth-ok"; userId: string }
  | { type: "unauthorized"; reason: string };

function ReauthBridge() {
  useSocketEvent(manager, "auth-required", async () => {
    const token = await auth.getValidAccessToken();
    manager.send({ data: { type: "auth", token } });
  });

  useSocketEvent(manager, "auth-expired", async () => {
    const token = await auth.refreshAccessToken();
    manager.send({ data: { type: "auth", token } });
  });

  return null;
}

Static token (simple)

If the token is available at manager construction and does not change while the tab is open, pass it as part of the URL string:

manager.ts
const token = getAccessToken();

const manager = new WebSocketManager<TClientMsg, TServerMsg>({
  url: `wss://api.example.com/ws?token=${token}`,
  serialize: (msg) => JSON.stringify(msg),
  deserialize: (raw) => JSON.parse(raw),
});

Dynamic token (refresh-safe)

Pass a function as url. The manager calls it on every connect and every reconnect, so it always resolves against the latest token:

manager.ts
const manager = new WebSocketManager<TClientMsg, TServerMsg>({
  url: () => `wss://api.example.com/ws?token=${tokenStore.get()}`,
  serialize: (msg) => JSON.stringify(msg),
  deserialize: (raw) => JSON.parse(raw),
});

The function can be async. Returning a Promise<string> is the way to wait for a fresh token before the socket opens:

manager.ts
const manager = new WebSocketManager<TClientMsg, TServerMsg>({
  url: async () => {
    const token = await auth.getValidAccessToken();
    return `wss://api.example.com/ws?token=${token}`;
  },
  // ...
});

Token refresh flow

When your server rejects a stale token (for example by closing with a custom code), refresh the token and call forceReconnect(). Because the URL is a function, the new connection picks up the fresh value automatically:

auth.ts
const manager = new WebSocketManager<TClientMsg, TServerMsg>({
  url: async () => {
    const token = await auth.getValidAccessToken();
    return `wss://api.example.com/ws?token=${token}`;
  },
  serialize: (msg) => JSON.stringify(msg),
  deserialize: (raw) => JSON.parse(raw),
});

// Optional: react to reconnect loops here.
manager.addConnectionStateListener(() => {
  const state = manager.getConnectionState();
  // ...
});

// Elsewhere: a server message tells you the token is dead
function handleUnauthorized() {
  auth.invalidateAccessToken();
  manager.forceReconnect();
}

If your server closes the socket with a non-1000 code, the manager reconnects on its own and calls the URL function again. You only need forceReconnect() when the server cannot or will not close the socket for you (for example if it just sends an unauthorized message).

Reacting to server-side unauth

If the server sends a typed unauthorized message before closing, handle it at the edge where messages are received:

UnauthorizedHandler.tsx
type TServerMsg =
  | { type: "unauthorized"; reason: string }
  | { type: "data"; /* ... */ };

useSocketEvent(manager, "unauthorized", async () => {
  await auth.refreshAccessToken();
  manager.forceReconnect();
});

Subprotocol authentication

Some servers accept a token through the WebSocket subprotocol header. Use manager.setProtocols() before calling connect():

manager.ts
const manager = new WebSocketManager<TClientMsg, TServerMsg>({
  url: "wss://api.example.com/ws",
  serialize: (msg) => JSON.stringify(msg),
  deserialize: (raw) => JSON.parse(raw),
});

manager.setProtocols(["bearer", token]);
manager.connect();

Security notes

  • Prefer wss:// in production so tokens are encrypted in transit.
  • Tokens in query strings can end up in proxy logs. Use short-lived access tokens.
  • Do not ship long-lived refresh tokens to the browser. Refresh over HTTPS, then pass the access token to the socket URL.
  • HTTP-only cookies avoid touching the URL at all and are the safest default if your server supports them.

Next steps