First-message auth
Browser WebSockets cannot carry an Authorization
header. Tokens in the URL end up in proxy and server logs. The cleaner
pattern is to open the socket with no credentials, then send an
auth frame as the very first message. The
manager's onReady hook fires on every
(re)connect, so this also handles reconnects for free.
Live Demo
First-message auth
idleWaiting for the first event…
Simulate session expiry asks the server to declare the
current session dead. The server emits auth-expired,
the client mints a fresh token and sends a new auth frame
on the same open socket — no reconnect.
Sign in with fresh token sends a new auth frame with a minted token. Use it to simulate a manual re-login.
Try bad token sends an auth frame the server will reject.
The server replies unauthorized and the UI flips
to the gated state.
Protocol
Three typed server messages cover the whole surface:
auth-ok (the happy path),
auth-expired (refresh, stay connected), and
unauthorized (hard stop).
type TClientMsg =
| { type: "auth"; token: string }
| { type: "simulate-session-expiry" };
type TServerMsg =
| { type: "auth-required" }
| { type: "auth-ok"; userId: string }
| { type: "auth-expired" }
| { type: "unauthorized"; reason: string };Sending the first frame
onReady runs after the socket opens and
subscriptions are replayed. On reconnects it runs again, so you do not
need a separate effect to re-auth.
const manager = new WebSocketManager<TClientMsg, TServerMsg>({
// No token in the URL. The socket opens unauthenticated.
url: getWsUrl(),
serialize: (msg) => JSON.stringify(msg),
deserialize: (raw) => JSON.parse(raw),
// Fires after every (re)connect + subscription replay.
// Right seam for the first-message auth frame.
onReady() {
const token = tokenStore.current;
if (!token) return;
manager.send({ data: { type: "auth", token } });
},
});Reacting to server events
useSocketEvent(manager, "auth-expired", () => {
setAuthed(false);
// Refresh the token and send a new auth frame.
// No reconnect: the socket stays open.
const next = mintToken();
tokenStore.current = next;
manager.send({ data: { type: "auth", token: next } });
});
// msg is narrowed to { type: "auth-ok"; userId: string } — no cast
useSocketEvent(manager, "auth-ok", (msg) => {
setAuthed(true);
setUserId(msg.userId);
});
useSocketEvent(manager, "unauthorized", () => {
setAuthed(false);
setUserId(null);
});