WebSocket: How the Connection Is Established, and How Do You Secure It?
Imagine you’re building a realtime chat feature. You pick WebSocket, the code works great, messages fly back and forth smoothly. Then comes security review day, and one simple question stops the whole team: “How does the server know which user owns this connection? And what stops a random website from opening a connection to our server?”
That’s when you realize: every familiar HTTP auth mechanism — the Authorization header, middleware checking a token on every request — doesn’t apply directly to WebSocket. The reason lies in how the connection is established.
This post covers two parts: how the WebSocket handshake works, and from there, the practical layers of defense for a WebSocket server.
1. How is a WebSocket connection established?
WebSocket is a protocol that lets a client and a server maintain a two-way connection (full-duplex — both sides can actively send data at any time) over a single TCP connection, instead of HTTP’s one-way request-response model.
The interesting part is that WebSocket doesn’t open its own special kind of connection. It starts as a regular HTTP request, called the handshake:
GET /chat HTTP/1.1
Host: example.com
Upgrade: websocket
Connection: Upgrade
Sec-WebSocket-Key: dGhlIHNhbXBsZSBub25jZQ==
Sec-WebSocket-Version: 13
Origin: https://example.com
Cookie: session=abc123Three things worth noting:
Upgrade: websockettells the server that the client wants to switch protocols from HTTP to WebSocket on this very TCP connection.Sec-WebSocket-Keyis a random base64 string. The server concatenates it with a fixed GUID defined in the WebSocket standard (RFC 6455), hashes the result with SHA-1, and returns it in theSec-WebSocket-Acceptheader. This key pair is not a security mechanism — it only proves the server actually speaks the WebSocket protocol, preventing a proxy or a plain HTTP server from accidentally returning a cached response.- Because the handshake is HTTP, it carries everything an HTTP request has: the URL (including the query string), headers, and cookies. This detail is the foundation for the authentication section below.
If the server agrees, it responds with:
HTTP/1.1 101 Switching Protocols
Upgrade: websocket
Connection: Upgrade
Sec-WebSocket-Accept: s3pPLMBiTxaQ9kYGzzhZRbK+xOo=Status code 101 Switching Protocols means: from this point on, the TCP connection stays open but both sides stop speaking HTTP. Data switches to WebSocket’s binary frames, and the concepts of request and response no longer exist — just two sides sending each other messages.
2. Why is securing WebSocket harder than HTTP?
The way the connection is established leads to three consequences that make the security problem very different from HTTP:
- You can’t set custom headers from the browser. The browser’s WebSocket API (
new WebSocket(url)) only accepts a URL and subprotocols; there is nowhere to pass headers. That means the familiarAuthorization: Bearer <token>pattern is off the table. - Authentication can only happen once, at the handshake. After the upgrade, there are no more requests for middleware to intercept and check. The server must decide “whose connection is this, and is it allowed” right at the handshake, then attach that identity to the connection.
- The handshake is not blocked by the Same-Origin Policy. The Same-Origin Policy is the browser mechanism that prevents JavaScript on site A from reading data from site B on a different origin, and CORS is how a server relaxes that mechanism in a controlled way. But the WebSocket handshake sits outside both: JavaScript on any web page can call
new WebSocket('wss://your-server.com')and the browser will send the request, along with the user’s cookies if there are any.
The third consequence is the most dangerous one, and it has its own name: Cross-Site WebSocket Hijacking (CSWSH) — a user is logged into your app, happens to open a malicious page, that page silently opens a WebSocket to your server carrying the user’s session cookie, and freely chats and reads data on the victim’s behalf.
From here, we build the layers of defense.
3. The foundation: always use wss
wss:// is WebSocket running over TLS (Transport Layer Security — the protocol that encrypts data in transit, the very S in HTTPS), the same relationship https:// has with http://. Using wss:// gives you two things:
- The entire handshake and every message are encrypted, protecting against eavesdropping and tampering in transit. This matters especially because tickets and cookies all travel through the handshake.
- More reliable connections in practice: many proxies and middleboxes that don’t understand WebSocket will cut or mangle a plain
ws://connection, whilewss://traffic is wrapped in TLS on port 443 and passes through cleanly.
This layer is mandatory, not optional. Everything below assumes you’re already on wss://.
4. Stopping CSWSH: validate the Origin header
The browser always automatically attaches the Origin header (the origin of the page running the JavaScript that opens the connection) to the WebSocket handshake, and JavaScript cannot forge this header. So the first line of defense against CSWSH is very simple: the server only accepts handshakes whose Origin is on an allowlist.
import { WebSocketServer } from 'ws'
const ALLOWED_ORIGINS = new Set(['https://example.com', 'https://app.example.com'])
const wss = new WebSocketServer({
port: 8080,
verifyClient: ({ origin }) => ALLOWED_ORIGINS.has(origin),
})One caveat: Origin is only trustworthy when the request comes from a browser. A hand-written client (a script, curl, a native app) can set Origin to anything. So Origin validation only stops cross-site attacks through browsers — it does not replace authentication, it complements it.
5. Authentication: two pragmatic approaches
5.1. Ticket via query params
Since you can’t attach an Authorization header, a common pattern is ticket-based authentication: use the HTTP channel you already have (where auth works normally) to request a ticket, then present that ticket when opening the WebSocket.
The flow has three steps:
- The client calls an already-protected HTTP endpoint (say
POST /ws-ticket) to request a ticket. The server generates a random string, stores it alongside the user ID with a short lifetime (about 30 seconds), and returns it to the client. - The client opens the connection with the ticket on the query string:
wss://example.com/chat?ticket=abc... - The server validates the ticket at the handshake, deletes the ticket right after its first use, and attaches the user ID to the connection.
import { randomBytes } from 'crypto'
const tickets = new Map<string, { userId: string; expiresAt: number }>()
function issueTicket(userId: string) {
const ticket = randomBytes(32).toString('hex')
tickets.set(ticket, { userId, expiresAt: Date.now() + 30_000 })
return ticket
}
function redeemTicket(ticket: string) {
const entry = tickets.get(ticket)
tickets.delete(ticket)
if (!entry || entry.expiresAt < Date.now()) return null
return entry.userId
}Why does it have to be a short-lived, single-use ticket instead of stuffing a JWT or session token straight into the query string? Because query strings get logged everywhere: server access logs, proxy and load balancer logs, browser history. A long-lived token sitting in a log is a leaked credential. But a ticket that lives 30 seconds and was invalidated after its first use is worthless to anyone reading those logs.
In production, the in-memory Map should be replaced with Redis with a TTL so this works correctly when the server runs multiple instances.
5.2. Cookie with SameSite
The second approach leverages the very fact that “the handshake is HTTP”: the browser automatically sends cookies with it. If your app already uses a session cookie for its HTTP side, the WebSocket server can read that cookie at the handshake and identify the user — no extra ticket step needed.
But recall section 2: the browser auto-sending cookies is exactly what makes CSWSH possible. This is where the SameSite attribute comes in. SameSite is a cookie attribute that governs whether the browser may send that cookie along with requests originating from a different site:
SameSite=Strict— the cookie is only sent when the request originates from the site itself.SameSite=Lax— like Strict, but relaxed for normal navigation (a user clicking a link to your site). A WebSocket request opened by another page still gets its cookie stripped.SameSite=None— the cookie is sent on every cross-site request, meaning you give up this layer of protection entirely.
With SameSite=Lax or Strict, the CSWSH scenario collapses: the malicious page can still open a connection to your server, but the browser refuses to attach the session cookie, so the handshake arrives anonymous and gets rejected.
res.setHeader('Set-Cookie', 'session=abc123; HttpOnly; Secure; SameSite=Lax; Path=/')The two accompanying attributes matter just as much: HttpOnly prevents JavaScript from reading the cookie (limiting the damage of XSS — an attacker managing to inject a script into your page), and Secure ensures the cookie is only ever sent over an encrypted connection.
One note: don’t drop the Origin validation from section 4 just because you have SameSite. The two layers protect in different ways, and good security always stacks multiple layers (defense in depth) — if one layer fails, another still holds.
6. Hardening after the handshake
A finished handshake doesn’t mean security is done. A few things worth doing for the rest of the connection’s life:
- Authorize every message. Being connected doesn’t mean being allowed to do everything. Every message that requests an action (join a room, delete data) must still be permission-checked against the user ID attached at the handshake.
- Limit message size. The
wslibrary has amaxPayloadoption — a message of a few hundred MB can knock the server over if you don’t block it. - Limit connections and message rate per user. WebSocket connections hold resources for a long time, so one user opening thousands of connections or blasting messages nonstop is a cheap form of denial-of-service attack.
- Ping pong periodically. The WebSocket protocol has a built-in pair of ping and pong frames to check that the other end is still alive. The server should ping on an interval and close connections that don’t respond, so dead connections don’t pile up and hog resources.
7. Conclusion
Back to the questions from the opening: how does the server know whose connection it is, and what stops a random website from connecting? The answer boils down to a few points:
- WebSocket starts as an HTTP request with an
Upgradeheader; after the 101 Switching Protocols response, the TCP connection stays open and switches to two-way frames. - Because the browser doesn’t allow custom headers and there are no more requests after the upgrade, all authentication must happen at the handshake.
- Always use
wss://— it both encrypts the credentials inside the handshake and helps the connection survive intermediate proxies. - Validate the
Originheader to block Cross-Site WebSocket Hijacking from browsers. - Authenticate with a short-lived, single-use ticket via query params when the WebSocket server is separate, or a session cookie with SameSite when it’s on the same domain — and whichever you pick, keep Origin validation as a second layer.
- After the handshake: authorize every message, cap the payload, cap connections, and ping pong to clean up dead connections.