Validating WebSocket Messages with Zod #
The handler reads msg.payload.roomId and the process crashes because payload was a string. Or it does not crash — it writes undefined into a Redis key and quietly corrupts a room. Every message arriving on a WebSocket is attacker-controlled input from an authenticated client, and unlike an HTTP endpoint there is no framework in front of it doing body parsing, content-type checks or schema validation. Whatever your onmessage handler assumes about shape is an assumption a client can violate.
Zod fixes this properly rather than defensively: instead of scattering if (typeof x !== 'string') through handlers, you parse once at the boundary and everything downstream is typed.
Root cause #
JSON.parse returns any. TypeScript will happily let you write msg.d.roomId.trim() on it, and the compiler is not lying to you — it simply has no information. The type you wrote in the interface describes what you hope arrives, not what does.
Three failure classes follow, in increasing order of cost:
Crashes. A property access on undefined inside an event handler throws, and an unhandled exception in an onmessage handler can take down the process or, worse, kill only that handler and leave the socket silently inert.
Corruption. A wrong-typed value passes through validation-free code and lands in state — a number where a string was expected, an object where an id was expected — and now the bug is in your data rather than your logs.
Amplification. A message with a huge nested array reaches a fan-out loop before anyone checks its size, and one client turns 4 KB into 40 MB of outbound traffic.
The last one is why validation order matters: check the byte length before you parse, check the shape before you route, and route before you do anything expensive.
Resolution #
One discriminated union describes every message the server accepts. The router is then exhaustive by construction: adding a message type without a handler is a compile error, not a runtime surprise.
import { z } from 'zod';
import { WebSocket } from 'ws';
const MAX_MESSAGE_BYTES = 64 * 1024;
// Each message type gets its own schema; the `t` field discriminates.
const JoinRoom = z.object({
t: z.literal('room.join'),
d: z.object({
roomId: z.string().uuid(),
since: z.number().int().nonnegative().optional(),
}),
});
const PostMessage = z.object({
t: z.literal('room.post'),
d: z.object({
roomId: z.string().uuid(),
body: z.string().min(1).max(4000),
// Idempotency key: the client generates it so a retry is deduplicated.
clientMsgId: z.string().uuid(),
}),
});
const Typing = z.object({
t: z.literal('room.typing'),
d: z.object({ roomId: z.string().uuid(), isTyping: z.boolean() }),
});
const Envelope = z.object({
v: z.number().int().min(1).max(2),
s: z.number().int().nonnegative(),
}).and(z.discriminatedUnion('t', [JoinRoom, PostMessage, Typing]));
export type Inbound = z.infer<typeof Envelope>;
const SCOPES: Record<Inbound['t'], string> = {
'room.join': 'room:read',
'room.post': 'room:write',
'room.typing': 'room:write',
};
export function handleRaw(socket: WebSocket, raw: Buffer, scopes: Set<string>): void {
// 1. Size first: rejecting here costs nothing, parsing a 40 MB body costs everything.
if (raw.byteLength > MAX_MESSAGE_BYTES) {
return reject(socket, 'too_large');
}
// 2. Malformed JSON is an expected condition on a public endpoint, not an error.
let parsed: unknown;
try { parsed = JSON.parse(raw.toString('utf8')); } catch { return reject(socket, 'malformed'); }
// 3+4. One parse gives a fully typed value or a structured failure.
const result = Envelope.safeParse(parsed);
if (!result.success) {
// Send the client the FIELD PATHS that failed, never the raw Zod message,
// which can echo input back and leak internals into logs and screenshots.
return reject(socket, 'invalid', result.error.issues.map((i) => i.path.join('.')));
}
const msg = result.data;
// 5. Authorise the specific message type, reading live scopes rather than any
// captured at connection time — tokens rotate while the socket stays open.
if (!scopes.has(SCOPES[msg.t])) return reject(socket, 'forbidden');
// 6. Exhaustive routing: adding a variant without a case fails to compile.
switch (msg.t) {
case 'room.join': return onJoin(socket, msg.d);
case 'room.post': return onPost(socket, msg.d);
case 'room.typing': return onTyping(socket, msg.d);
default: {
const _never: never = msg;
return reject(socket, 'unknown_type');
}
}
}
function reject(socket: WebSocket, reason: string, fields?: string[]): void {
if (socket.readyState === WebSocket.OPEN) {
socket.send(JSON.stringify({ t: 'error', reason, fields }));
}
}
The const _never: never = msg line in the default branch is what makes the router exhaustive. Add a fourth message type to the union without adding a case and TypeScript refuses to compile, which is a far better guard than a test you might forget to write.
Note what the error frame does not contain: the offending value, the Zod message, or a stack. Field paths are enough for a client developer to fix the bug and carry no risk of echoing a token or a password that was mistakenly sent in the wrong field.
Verification #
Test the rejections as deliberately as the happy path — they are the part that protects you.
# A wrong-typed field must produce an error frame, not a crash
npx wscat -c ws://localhost:8080/ws -x '{"v":1,"s":1,"t":"room.post","d":{"body":42}}'
# An unknown type must be rejected without touching any handler
npx wscat -c ws://localhost:8080/ws -x '{"v":1,"s":1,"t":"room.delete_everything","d":{}}'
# Oversized payloads must be dropped before parse — watch CPU stay flat
head -c 200000 /dev/zero | tr '\0' 'a' | xargs -0 -I{} npx wscat -c ws://localhost:8080/ws -x '{}'
Then assert on the metric: ws_messages_rejected_total labelled by reason should be non-zero in production and dominated by malformed (scanners and stray proxies) rather than invalid (a real client bug). A rising invalid rate after a deploy is the fastest possible signal that a client and server disagree about the schema.
Operational checklist #
FAQ #
Does Zod add meaningful latency per message? #
Tens of microseconds for a typical envelope, against hundreds for the handling and fan-out that follow — so it is roughly a tenth of the work you were going to do anyway. If a specific hot path genuinely needs more, compile the schema once at module scope (never inside the handler), avoid .refine() chains on the hot path, and consider a faster validator with the same discriminated-union shape. Measure before optimising: in practice serialisation, not validation, is the CPU cost that matters.
Should the client validate too? #
Validate inbound messages on the client, yes — a server bug or a version skew can send a shape the UI does not expect, and a crash in a message handler freezes the whole real-time view. Share the schema module between client and server if they are in one repository; if not, generate both from the same definition rather than maintaining two by hand, which is exactly how they drift.
How do I evolve a schema without breaking older clients? #
Additively. New fields must be .optional(), new message types must be safely ignorable by clients that do not know them, and removals wait a full client-refresh cycle. Bump the envelope v only for incompatible changes to the envelope itself, and accept both versions in parallel during migration — the versioning discipline described in message framing and serialization.
What should the server do with an unknown message type? #
Reject it with a structured error and count it, but do not close the connection. An unknown type usually means a newer client talking to an older server during a rolling deploy, which is a transient condition that resolves itself; closing turns a harmless mismatch into a reconnect storm. Reserve closing for repeated policy violations, as covered in rate limiting WebSocket messages per client.
Does this work with binary payloads? #
Yes — decode first, then validate the resulting object with the same schema. The only change is step two: replace JSON.parse with your MessagePack or Protobuf decode, keep it inside the same try/catch, and everything downstream is identical. Zod validates the decoded value, not the wire format.
Related #
- Server-Side Routing Patterns — the router this validation feeds, and how message types map to handlers.
- Multi-Tenant WebSocket Channel Namespacing — why a validated
roomIdis a tenancy boundary, not just a string. - Message Framing & Serialization — the envelope shape the schema encodes and how to version it.
- Rotating WebSocket Tokens Without Dropping Connections — why scopes must be read live rather than captured.
- Rate Limiting WebSocket Messages Per Client — the other guard that belongs before the handler.
Back to Server-Side Routing Patterns