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:
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:
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:
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:
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:
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:
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:
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:
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():
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
- Reconnection — how backoff and state changes interact with token refresh
- Configuration — full list of manager options
- Error handling — surfacing auth failures to users