Securing WebSockets: Authentication in WebSocket Connections

WebSockets open the door for low-latency, real-time communication. But with great speed comes great responsibility — especially when it comes to securing your connections.
In this article, we’ll explore how to implement authentication for WebSockets using various patterns and best practices. This is the second article in our series using the native WebSocket client in Node.js 23 and the ws
package for the server.
"Trust, but verify." – Ronald Reagan
📖 Missed the first article? Exploring the New WebSocket Client in Node.js 23: What You Need to Know
🔒 Why Authentication Matters in WebSockets
Unlike HTTP requests, WebSockets are long-lived. Once the connection is open, there's no built-in request/response cycle to validate credentials per message. That means you need to authenticate at connection time — and also be ready to verify ongoing access if the client's state changes (like a subscription expiring).
🧪 What We’re Exploring
We’ll demonstrate and compare two common authentication patterns:
Query Parameters at handshake time
Bearer Tokens or JWTs sent after connection
We’ll also cover:
How to get the token using a REST API
How to pass and validate the token on the WebSocket connection
Per-message authorization for ongoing checks
⚙️ Getting the Token (Authentication Endpoint)
Before a WebSocket connection is made, the client typically authenticates via a standard HTTP endpoint.
// server.js
import express from 'express';
import { UNAUTHORIZED } from "http-response-status-code";
const app = express();
app.use(express.json());
app.post('/login', (req, res) => {
const { username, password } = req.body;
if (username === 'demo' && password === 'pass') {
const token = 'secret123'; // Replace with JWT in real apps
res.json({ token });
} else {
res.status(UNAUTHORIZED).json({ error: 'Invalid credentials' });
}
});
app.listen(3000, () => console.log('Auth server running'));
The client will first get a token like this:
// client.js
const res = await fetch('http://localhost:3000/login', {
method: 'POST',
headers: { 'Content-Type': 'application/json' },
body: JSON.stringify({ username: 'demo', password: 'pass' })
});
const { token } = await res.json();
1️⃣ Auth via Query Parameters
// server-query.js
import { WebSocketServer } from 'ws';
import url from 'url';
const wss = new WebSocketServer({ port: 8080 });
wss.on("connection", (ws, req) => {
const params = new URLSearchParams(url.parse(req.url).query);
const token = params.get("authToken");
if (!validateToken(token)) {
ws.close(1008, "Unauthorized");
return;
}
ws.on("message", (msg) => {
console.log("Query Token Message:", msg.toString());
ws.send("Hello from native server!")
});
});
function validateToken(token) {
return token === "secret123"; // mock validation
}
// client-query.js
const ws = new WebSocket(`ws://localhost:8080?authToken=${token}`);
ws.addEventListener("open", () => {
console.log("Connected to server");
ws.send("Hello from native client!");
});
ws.addEventListener("message", (event) => {
console.log("Received from server:", event.data);
});
ws.addEventListener("close", () => {
console.log("Closed from server.");
});
Pros: Easy to implement. Works in all browsers.
Cons: Token exposed in logs and URLs.
2️⃣ Auth via Bearer Token
The most secure and flexible pattern — send the token with every message:
// server-jwt.js
import { WebSocketServer } from 'ws';
const wss = new WebSocketServer({ port: 8080 });
wss.on("connection", (ws) => {
ws.on("message", (msg) => {
let parsed;
try {
parsed = JSON.parse(msg);
} catch (e) {
ws.send("Invalid message format");
return;
}
const { token, data } = parsed;
if (!validateToken(token)) {
ws.close(4001, "Unauthorized");
return;
}
console.log("Received:", data);
ws.send(`Echo: ${data}`);
});
});
function validateToken(token) {
return token === "secret123"; // mock validation
}
// client-jwt.js
const ws = new WebSocket(`ws://localhost:8080`);
ws.addEventListener("open", () => {
console.log("Connected to server");
ws.send(
JSON.stringify({
token,
data: "Hello from native client!",
})
);
});
ws.addEventListener("message", (event) => {
console.log("Received from server:", event.data);
});
ws.addEventListener("close", () => {
console.log("Closed from server.");
});
Pros: Stateless, flexible, secure
Cons: Slightly more verbose messages
🛡 Per-Message Authorization (Recommended Add-on)
To handle things like revoked tokens or expired subscriptions:
ws.on("message", (msg) => {
if (!validateToken(token)) {
ws.close(4001, "Unauthorized");
return;
}
// continue processing...
});
This ensures long-lived connections don’t bypass new restrictions.
✅ Auth Pattern Comparison
Pattern | Pros | Cons | Recommended? |
Query Params | Easy to set up | Exposed in URLs/logs | ❌ Simple demos only |
JWT Token | Secure, dynamic revocation | Slightly larger payload | ✅ Production ready |
🔐 Best Practices Summary
🧠 Use JWTs with expiry and claims
🔁 Validate per message for dynamic access control
🔒 Use
wss://
and HTTPS everywhere⚠️ Avoid putting secrets in URLs or logs
🔄 Rotate tokens periodically
📡 Use REST APIs for token acquisition before WS connection
"Security is not a product, but a process." – Bruce Schneier
💾 Sample Code on GitHub
Want to try it yourself? You can find the full example code used in this article in the GitHub repo:
👉 codanyks/native-websocket-nodejs
Feel free to clone, experiment, and modify to fit your use case.
💬 What’s Next?
We’ve locked down access. But what happens when a connection drops, or the server crashes?
Next up:
Error Handling in WebSockets: Ensuring Stability in Real-Time Communication — we’ll tackle retries, fallbacks, and graceful shutdowns.
Stay tuned!
Subscribe to my newsletter
Read articles from codanyks directly inside your inbox. Subscribe to the newsletter, and don't miss out.
Written by
