CORS and WebSockets
Here's a fact that makes people's eyebrows shoot up when I mention it at work:
WebSockets do not use CORS. At all. The entire preflight mechanism, the
Access-Control-Allow-Origin headers, the whole song and dance we've been
discussing throughout this book — none of it applies to WebSocket connections.
If you just felt a chill run down your spine thinking about the security implications, good. You're paying attention. Let's unpack this.
The WebSocket Handshake
A WebSocket connection starts as an HTTP request that gets "upgraded" to the WebSocket protocol. Here's what the handshake looks like on the wire:
>>> GET /chat HTTP/1.1
>>> Host: ws.example.com
>>> Upgrade: websocket
>>> Connection: Upgrade
>>> Sec-WebSocket-Key: dGhlIHNhbXBsZSBub25jZQ==
>>> Sec-WebSocket-Version: 13
>>> Origin: https://myapp.example.com
<<< HTTP/1.1 101 Switching Protocols
<<< Upgrade: websocket
<<< Connection: Upgrade
<<< Sec-WebSocket-Accept: s3pPLMBiTxaQ9kYGzzhZRbK+xOo=
Notice a few things:
- The browser does send an
Originheader. This is important. - There is no
Access-Control-Allow-Originin the response. - There is no preflight OPTIONS request.
- The server responds with
101 Switching Protocolsand that's it — you're on WebSockets now.
You can observe this handshake yourself. Open DevTools, go to the Network tab,
filter by "WS", and connect to a WebSocket server. You'll see the initial HTTP
request with the upgrade headers and the Origin header right there.
Why WebSockets Bypass CORS
The WebSocket protocol predates the modern CORS specification, and the two were
designed by different groups with different security models. The CORS spec
(maintained as part of the Fetch Standard) explicitly scopes itself to HTTP
requests made via fetch(), XMLHttpRequest, and similar APIs. The WebSocket
API is a different beast.
The reasoning, as far as I can reconstruct it from spec discussions, goes something like this:
- WebSocket connections are initiated explicitly by JavaScript calling
new WebSocket(url). There's no ambient authority problem like there is with cookies being automatically attached to HTTP requests. - The WebSocket handshake includes an
Originheader that the server is expected to validate. - The spec authors decided that origin validation for WebSockets should be the server's responsibility, not the browser's.
Whether you agree with this design decision is a separate matter. The practical consequence is that you must implement origin checking yourself on the server side. The browser will not protect you.
The Origin Header in WebSocket Handshakes
Even though the browser doesn't enforce CORS for WebSockets, it does faithfully
send the Origin header. This is your lifeline.
When a page at https://myapp.example.com opens a WebSocket:
const ws = new WebSocket("wss://ws.example.com/chat");
The handshake request will include:
Origin: https://myapp.example.com
When a page at https://evil-site.example.net tries to connect to your WebSocket
server:
Origin: https://evil-site.example.net
The browser cannot lie about the Origin header in a WebSocket handshake. It's
set by the browser itself, not by JavaScript. Your server can trust it — at least
when the request comes from a browser.
Important caveat: Non-browser clients (curl, Postman, custom scripts) can set
the Origin header to anything they want. Origin validation protects you against
cross-site attacks in browsers, not against determined attackers with custom HTTP
clients. But that's true of CORS in general — it's a browser security mechanism,
not a server authentication mechanism.
# A non-browser client can fake the origin:
curl -v \
--include \
-H "Connection: Upgrade" \
-H "Upgrade: websocket" \
-H "Sec-WebSocket-Version: 13" \
-H "Sec-WebSocket-Key: $(openssl rand -base64 16)" \
-H "Origin: https://definitely-not-evil.com" \
https://ws.example.com/chat
Server-Side Origin Validation
Since the browser won't do CORS enforcement for you, you need to validate the
Origin header yourself in your WebSocket server. Here's how that looks in a
few popular frameworks.
Node.js with the ws Library
const WebSocket = require("ws");
const ALLOWED_ORIGINS = [
"https://myapp.example.com",
"https://staging.myapp.example.com"
];
const wss = new WebSocket.Server({
port: 8080,
verifyClient: (info, callback) => {
const origin = info.origin || info.req.headers.origin;
if (!origin || !ALLOWED_ORIGINS.includes(origin)) {
console.log(`Rejected WebSocket connection from origin: ${origin}`);
callback(false, 403, "Forbidden");
return;
}
callback(true);
}
});
wss.on("connection", (ws, req) => {
console.log(`Connection from ${req.headers.origin}`);
ws.on("message", (message) => {
console.log(`Received: ${message}`);
ws.send(`Echo: ${message}`);
});
});
Python with websockets
import asyncio
import websockets
ALLOWED_ORIGINS = {
"https://myapp.example.com",
"https://staging.myapp.example.com",
}
async def handler(websocket):
origin = websocket.origin
if origin not in ALLOWED_ORIGINS:
await websocket.close(4003, f"Origin {origin} not allowed")
return
async for message in websocket:
await websocket.send(f"Echo: {message}")
async def main():
# The `origins` parameter does built-in origin checking:
async with websockets.serve(
handler,
"0.0.0.0",
8080,
origins=ALLOWED_ORIGINS
):
await asyncio.Future() # Run forever
asyncio.run(main())
Go with gorilla/websocket
package main
import (
"log"
"net/http"
"github.com/gorilla/websocket"
)
var allowedOrigins = map[string]bool{
"https://myapp.example.com": true,
"https://staging.myapp.example.com": true,
}
var upgrader = websocket.Upgrader{
CheckOrigin: func(r *http.Request) bool {
origin := r.Header.Get("Origin")
return allowedOrigins[origin]
},
}
func handleWebSocket(w http.ResponseWriter, r *http.Request) {
conn, err := upgrader.Upgrade(w, r, nil)
if err != nil {
log.Printf("Upgrade failed: %v", err)
return
}
defer conn.Close()
for {
messageType, message, err := conn.ReadMessage()
if err != nil {
break
}
conn.WriteMessage(messageType, message)
}
}
func main() {
http.HandleFunc("/ws", handleWebSocket)
log.Fatal(http.ListenAndServe(":8080", nil))
}
Note: gorilla/websocket's default CheckOrigin rejects all cross-origin
requests if you don't set it. Many tutorials tell you to set it to
func(r *http.Request) bool { return true } to "fix" WebSocket connection
issues. Please don't do that in production. That's the equivalent of setting
Access-Control-Allow-Origin: * — except worse, because at least CORS has
other protections built in.
ws:// vs wss:// Security Implications
Just like HTTP vs HTTPS, WebSockets have an unencrypted variant (ws://) and
a TLS-encrypted variant (wss://):
| Scheme | Port (default) | Encrypted | Mixed content |
|---|---|---|---|
ws:// | 80 | No | Blocked from HTTPS pages |
wss:// | 443 | Yes | Allowed from HTTPS pages |
The critical point: modern browsers block ws:// connections from pages served
over HTTPS. This is the mixed content policy, and it applies to WebSockets just
as it does to HTTP subresources.
// On a page served over https://myapp.example.com:
// This will be BLOCKED (mixed content):
const ws1 = new WebSocket("ws://ws.example.com/chat");
// Error in console: Mixed Content: The page was loaded over HTTPS,
// but attempted to connect to the insecure WebSocket endpoint
// 'ws://ws.example.com/chat'.
// This works:
const ws2 = new WebSocket("wss://ws.example.com/chat");
In development, you might use ws://localhost:8080 and that's fine — browsers
typically exempt localhost from mixed content restrictions. But in production,
always use wss://.
Also worth noting: because wss:// goes through TLS, the WebSocket handshake is
encrypted. This means proxies and firewalls can't inspect or modify the Origin
header. Without TLS, a man-in-the-middle could theoretically modify the Origin
header during the handshake, bypassing your server-side origin validation.
Socket.IO and Its Own CORS Configuration
If you're using Socket.IO, buckle up, because it has its own CORS configuration that's separate from both your HTTP server's CORS setup and the WebSocket protocol's lack of CORS.
Socket.IO starts with HTTP long-polling and then upgrades to WebSockets. The long-polling phase uses regular HTTP requests, which means CORS applies. When it upgrades to WebSockets, CORS no longer applies. Socket.IO handles both, but you need to configure it properly.
Server-Side (Node.js)
const { Server } = require("socket.io");
// Socket.IO v4 — CORS must be explicitly configured
const io = new Server(3000, {
cors: {
origin: ["https://myapp.example.com", "https://staging.myapp.example.com"],
methods: ["GET", "POST"],
credentials: true
}
});
io.on("connection", (socket) => {
console.log(`Client connected: ${socket.id}`);
console.log(`Origin: ${socket.handshake.headers.origin}`);
socket.on("chat message", (msg) => {
io.emit("chat message", msg);
});
});
Client-Side
import { io } from "socket.io-client";
const socket = io("https://api.example.com", {
withCredentials: true,
// Socket.IO will try WebSocket first (if available), falling back to polling
transports: ["websocket", "polling"]
});
socket.on("connect", () => {
console.log("Connected:", socket.id);
});
socket.on("connect_error", (err) => {
// This might be a CORS error during the polling phase
console.error("Connection error:", err.message);
});
A common pitfall: you've configured CORS on your Express app using the cors
middleware, but Socket.IO has its own CORS configuration. They don't share
settings. You need to configure both:
const express = require("express");
const { createServer } = require("http");
const { Server } = require("socket.io");
const cors = require("cors");
const app = express();
// CORS for your REST API endpoints
app.use(cors({
origin: "https://myapp.example.com",
credentials: true
}));
const httpServer = createServer(app);
// CORS for Socket.IO (separate configuration!)
const io = new Server(httpServer, {
cors: {
origin: "https://myapp.example.com",
credentials: true
}
});
// REST endpoint
app.get("/api/messages", (req, res) => {
res.json({ messages: [] });
});
// WebSocket handler
io.on("connection", (socket) => {
socket.on("message", (data) => {
io.emit("message", data);
});
});
httpServer.listen(3000);
I've lost count of the number of times I've seen someone configure CORS for their Express routes and then wonder why Socket.IO still throws CORS errors. It's always the polling transport. Always.
Common Pattern: REST + WebSockets in the Same App
A typical modern application uses REST APIs for CRUD operations and WebSockets for real-time updates. This means you're dealing with two different security models simultaneously:
REST API (https://api.example.com/messages)
└── Protected by CORS
└── Browser enforces Access-Control-Allow-Origin
└── Preflight for non-simple requests
WebSocket (wss://ws.example.com/live)
└── NOT protected by CORS
└── Server must validate Origin header manually
└── No preflight, ever
Here's what a complete setup might look like:
// Client-side
class MessageService {
constructor(apiBase, wsBase) {
this.apiBase = apiBase;
this.wsBase = wsBase;
this.ws = null;
this.listeners = new Set();
}
// REST: fetch messages (CORS-protected by browser)
async getMessages() {
const response = await fetch(`${this.apiBase}/messages`, {
credentials: "include"
});
return response.json();
}
// REST: send a message (CORS-protected by browser)
async sendMessage(text) {
const response = await fetch(`${this.apiBase}/messages`, {
method: "POST",
credentials: "include",
headers: { "Content-Type": "application/json" },
body: JSON.stringify({ text })
});
return response.json();
}
// WebSocket: real-time updates (NOT CORS-protected)
connect() {
this.ws = new WebSocket(`${this.wsBase}/live`);
this.ws.onmessage = (event) => {
const data = JSON.parse(event.data);
this.listeners.forEach(fn => fn(data));
};
this.ws.onclose = () => {
// Reconnect after a delay
setTimeout(() => this.connect(), 3000);
};
}
onMessage(callback) {
this.listeners.add(callback);
return () => this.listeners.delete(callback);
}
}
const service = new MessageService(
"https://api.example.com",
"wss://ws.example.com"
);
On the server side, you need to remember that these two transports have different security requirements:
// Server-side: two security models, one application
// REST: CORS middleware handles origin checking
app.use("/api", cors({
origin: "https://myapp.example.com",
credentials: true
}));
// WebSocket: manual origin checking
wss.on("connection", (ws, req) => {
const origin = req.headers.origin;
if (origin !== "https://myapp.example.com") {
ws.close(4003, "Origin not allowed");
return;
}
// Also validate the user's session/token
const token = new URL(req.url, "https://ws.example.com")
.searchParams.get("token");
if (!isValidToken(token)) {
ws.close(4001, "Unauthorized");
return;
}
});
Security: What Happens If You Skip Origin Validation
Let's be concrete about the risk. If your WebSocket server accepts connections from any origin, here's an attack scenario:
- User logs into
https://yourapp.comand gets a session cookie. - User visits
https://evil-site.com(maybe they clicked a link in an email). evil-site.comruns JavaScript that opens a WebSocket towss://ws.yourapp.com/live.- The browser sends the
Origin: https://evil-site.comheader, but your server doesn't check it. - If your WebSocket server uses cookies for authentication (or the connection
inherits the user's session),
evil-site.comnow has a live WebSocket connection to your server, authenticated as the user. - The attacker can now receive real-time data meant for the user, or send messages as the user.
This is essentially a Cross-Site WebSocket Hijacking (CSWSH) attack. It's the
WebSocket equivalent of CSRF, and it's entirely preventable by checking the
Origin header.
# Demonstrating the attack with curl:
# An attacker's page could do the equivalent of this:
curl -v \
--include \
-H "Connection: Upgrade" \
-H "Upgrade: websocket" \
-H "Sec-WebSocket-Version: 13" \
-H "Sec-WebSocket-Key: $(openssl rand -base64 16)" \
-H "Origin: https://evil-site.com" \
-H "Cookie: session=stolen-or-browser-attached-cookie" \
https://ws.yourapp.com/live
If your server responds with 101 Switching Protocols to this request, you have
a vulnerability.
Best Practices for WebSocket Security
- Always validate the
Originheader. Use an allowlist, not a blocklist. - Don't rely solely on cookies for WebSocket authentication. Use a short-lived token passed as a query parameter or in the first message.
- Use
wss://in production. Always. No exceptions. - Implement rate limiting on WebSocket connections per IP and per user.
- Validate messages. Just because a connection was authenticated at handshake time doesn't mean every message is safe.
// Better authentication pattern: token-based
// Step 1: Get a short-lived WebSocket token via your REST API (CORS-protected)
const response = await fetch("https://api.example.com/ws-token", {
method: "POST",
credentials: "include"
});
const { token } = await response.json();
// Step 2: Use the token to connect (token is single-use, short-lived)
const ws = new WebSocket(`wss://ws.example.com/live?token=${token}`);
This way, even if an attacker can initiate a WebSocket connection from an evil page, they can't get a valid token because the REST endpoint that issues tokens is protected by CORS and requires the user's cookies to be sent from an allowed origin.
Summary
| Feature | HTTP (fetch/XHR) | WebSocket |
|---|---|---|
| CORS enforcement | Browser-enforced | None |
| Origin header sent | Yes | Yes |
| Origin validation | By browser (CORS) | By server (manual) |
| Preflight requests | Yes (for non-simple) | Never |
| Cookie handling | Controlled by credentials | Sent automatically (same-origin cookies) |
| Mixed content blocked | Yes (HTTP from HTTPS page) | Yes (ws:// from HTTPS page) |
The key takeaway: WebSockets give you more power and more responsibility. The
browser sends the Origin header but won't enforce anything based on the
response. If you're running a WebSocket server that accepts connections from web
browsers, validating the Origin header is not optional — it's the only thing
standing between you and cross-site WebSocket hijacking.