๐Ÿš€ Multi-Tenant WebSocket Server

Real-time communication with Socket.IO, Express.js & per-app credentials backed by a shared database. Live test client.

๐Ÿ“‹ Overview

This server powers real-time features for multiple independent applications ("tenants") from a single Node.js process. Each tenant is called an App and owns its own credentials, namespace, and channels. Apps are managed through the companion Laravel WebSockets Manager; this Node server reads the same database to validate requests.

๐Ÿข Per-App Isolation

Each App has its own Socket.IO namespace (/<appKey>) and channels. Apps cannot reach each other's clients.

๐Ÿ” Two-Layer Auth

Backends authenticate with X-App-Key + X-App-Secret. End-user clients connect with short-lived JWTs minted by the backend.

๐Ÿ“ก Channels

Public and private (private-) channels within each App. Channel authorization is baked into the JWT.

๐Ÿ—„๏ธ DB-Backed

App credentials and settings live in MySQL, shared with the Laravel manager. Changes take effect immediately.

๐Ÿงญ Concepts

App

An App is a tenant. It's created in the Laravel manager and stored in the apps table. Each App has:

  • id — numeric primary key (the "APP_ID")
  • key — public identifier, used as the Socket.IO namespace (the "APP_KEY")
  • secret — private, shared with the tenant's backend only (the "APP_SECRET")
  • allowed_origins, max_connections, enabled — enforced at connect time

Two layers of authentication

Layer 1 — App credentials (server-to-server)

Headers X-App-Key + X-App-Secret authorize the tenant's backend to mint tokens and broadcast events. Never expose these to browsers.

Layer 2 — JWT (server-to-end-user)

The tenant's backend mints a JWT for each end user. The JWT embeds the user's identity and the list of channels they're authorized to join. Clients use this JWT (and only this JWT) to connect.

Channels

Within an App's namespace, clients communicate over channels. Channel names must be lowercase alphanumeric with ., -, _ allowed.

PrefixTypeNotes
(none)Publice.g. chat-general. Still gated by JWT.
private-Privatee.g. private-user-42. Convention only; authorization is the JWT.

All channels live inside an App. private-user-42 on App A is completely separate from private-user-42 on App B.

Who does what?

ActorUsesTypical calls
Tenant backend (e.g. Swaliset)App Key + App SecretPOST /auth/get-token, POST /apps/<key>/events
End user browserJWT from their backendSocket.IO connect + client-event
โš™๏ธ Getting Started

1. Requirements

  • Node.js 18+
  • MySQL (shared with the Laravel WebSockets Manager)
  • The apps table populated via the manager

2. Environment variables

Create .env in the project root:

NODE_ENV=production PORT=3000 DOMAIN=your-domain.com # JWT signing secret (internal โ€” NOT shared with tenants). # Keep this stable across restarts or existing tokens become invalid. JWT_SECRET=a-long-random-string JWT_TTL_SECONDS=7200 # MySQL โ€” same DB as the Laravel manager DB_HOST=127.0.0.1 DB_PORT=3306 DB_DATABASE=mywebsockets DB_USERNAME=root DB_PASSWORD= DB_CONNECTION_LIMIT=10

3. Install & run

npm install node index.js

On boot you should see:

โœ… MySQL connected ๐Ÿš€ WebSocket server running on 127.0.0.1:3000 ๐ŸŒ Environment: production

4. Create your first App

In the Laravel WebSockets Manager:

  1. Log in and go to /apps/create
  2. Give it a name. Optionally restrict Allowed Origins and Max Connections
  3. Copy the APP_KEY and APP_SECRET shown on the detail page — share the secret with the tenant's backend only

5. Health check

curl http://127.0.0.1:3000/health # {"ok":true,"ts":1777034640123}
๐Ÿ“ก HTTP API

All backend endpoints require both headers:

X-App-Key: <app.key> X-App-Secret: <app.secret>

GET /health

Public. Used for uptime monitoring.

{"ok": true, "ts": 1777034640123}

POST /auth/get-token

Mint a connection token for an end user. Called by the tenant's backend.

Headers
X-App-Key: <app.key> X-App-Secret: <app.secret> Content-Type: application/json
Body
{ "user_id": "user-123", "user_info": { "name": "Jane Doe", "role": "student" }, "channels": ["chat-general", "private-user-123"] }
Success response
{ "token": "eyJhbGciOiJIUzI1NiIs...", "appKey": "aafq136csrjlqbos28vu", "userId": "user-123", "channels": ["chat-general", "private-user-123"], "iat": 1777034640, "exp": 1777041840, "expires_in": 7200 }
Error responses
StatusMeaning
400Missing user_id, missing/empty channels, or invalid channel name
401Missing or invalid X-App-Key / X-App-Secret
403App exists but is disabled

POST /apps/:appKey/events

Broadcast an event to a channel. The URL :appKey must match the authenticated App.

Body
{ "channel": "chat-general", "event": "message", "data": { "text": "Hello everyone!" }, "except_socket_id": "abc123" }

except_socket_id is optional. When provided, that socket will not receive the event — useful to avoid echoing a message back to the sender that already rendered it locally. The same end user on a different device still receives the event.

Success response
{"ok": true, "channel": "chat-general", "event": "message"}
๐Ÿ”Œ Client Connection

End-user clients connect with a JWT previously obtained from their backend.

Namespace

https://your-domain.com/<appKey>

The namespace is the App's public key. Upgrades to WebSocket automatically.

Connecting

const socket = io(`https://your-domain.com/${appKey}`, { auth: { token: jwtFromBackend }, transports: ['websocket', 'polling'], }); socket.on('connect', () => console.log('connected:', socket.id)); socket.on('connect_error', (e) => console.warn('failed:', e.message)); socket.on('disconnect', (r) => console.log('disconnected:', r));

On successful connect, the server automatically joins the socket to every channel embedded in the JWT — no explicit subscribe step.

Receiving events

Every event wraps the payload in a standard envelope:

{ "channel": "chat-general", "event": "message", "data": { "text": "Hello everyone!" }, "from": { "server": true } }

Use socket.on(event) for a specific event, or socket.onAny to catch all:

socket.on('message', (payload) => { console.log('channel:', payload.channel); console.log('data:', payload.data); }); // Or catch everything: socket.onAny((event, payload) => { console.log('[' + payload.channel + ']', event, payload.data); });

Sending messages to other clients

Use the client-event emit. The socket can only send on channels it's joined to (enforced server-side).

socket.emit( 'client-event', { channel: 'chat-general', event: 'message', data: 'Hello from the browser', }, (ack) => { if (ack?.ok) console.log('sent'); else console.warn('rejected:', ack?.error); } );

Other members of chat-general receive it with from.userId and from.userInfo set to the sender's identity from the JWT. The sender itself is excluded.

Error codes during connect

Error messageMeaning
No token providedMissing auth.token and ?token= query
Invalid or expired tokenJWT signature or exp problem
App key mismatchToken's appKey doesn't match the URL namespace
App not found / App disabledApp was deleted or disabled in the manager
Origin not allowedBrowser's Origin header is not in the App's allowed_origins
Max connections reachedApp hit its configured connection cap
๐Ÿ’ป Examples

Backend — Laravel (PHP)

use Illuminate\Support\Facades\Http; // 1. Mint a token for a logged-in user $response = Http::withHeaders([ 'X-App-Key' => config('websockets.app_key'), 'X-App-Secret' => config('websockets.app_secret'), ])->post(config('websockets.host') . '/auth/get-token', [ 'user_id' => (string) $user->id, 'user_info' => ['name' => $user->name], 'channels' => ['chat-general', "private-user-{$user->id}"], ]); $token = $response->json('token'); // 2. Broadcast an event Http::withHeaders([ 'X-App-Key' => config('websockets.app_key'), 'X-App-Secret' => config('websockets.app_secret'), ])->post( config('websockets.host') . '/apps/' . config('websockets.app_key') . '/events', [ 'channel' => 'chat-general', 'event' => 'post.created', 'data' => ['post' => $post->toArray()], ] );

Backend — Node.js

const fetch = require('node-fetch'); // or native fetch on Node 18+ async function mintToken(userId, channels) { const res = await fetch(`${WS_HOST}/auth/get-token`, { method: 'POST', headers: { 'Content-Type': 'application/json', 'X-App-Key': process.env.WS_APP_KEY, 'X-App-Secret': process.env.WS_APP_SECRET, }, body: JSON.stringify({ user_id: String(userId), channels, }), }); if (!res.ok) throw new Error(`token mint failed: ${res.status}`); return (await res.json()).token; } async function broadcast(channel, event, data) { await fetch(`${WS_HOST}/apps/${process.env.WS_APP_KEY}/events`, { method: 'POST', headers: { 'Content-Type': 'application/json', 'X-App-Key': process.env.WS_APP_KEY, 'X-App-Secret': process.env.WS_APP_SECRET, }, body: JSON.stringify({ channel, event, data }), }); }

Frontend — Browser

<script src="https://cdn.socket.io/4.8.1/socket.io.min.js"></script> <script> // tokenFromBackend came via your app's own page / API const socket = io(`https://your-domain.com/${APP_KEY}`, { auth: { token: tokenFromBackend } }); socket.on('connect', () => console.log('connected', socket.id)); socket.on('connect_error', (e) => console.error(e.message)); // Listen for server broadcasts socket.on('post.created', (payload) => { console.log('new post in', payload.channel, payload.data); }); // Send to a channel document.getElementById('send').onclick = () => { socket.emit('client-event', { channel: 'chat-general', event: 'message', data: 'Hello!' }); }; </script>

cURL

# Mint a token curl -X POST https://your-domain.com/auth/get-token \ -H "Content-Type: application/json" \ -H "X-App-Key: aafq136csrjlqbos28vu" \ -H "X-App-Secret: <app-secret>" \ -d '{ "user_id": "user-123", "user_info": { "name": "Jane" }, "channels": ["chat-general"] }' # Broadcast curl -X POST https://your-domain.com/apps/aafq136csrjlqbos28vu/events \ -H "Content-Type: application/json" \ -H "X-App-Key: aafq136csrjlqbos28vu" \ -H "X-App-Secret: <app-secret>" \ -d '{ "channel": "chat-general", "event": "message", "data": "Hello everyone!" }'
๐ŸŒ Hosting

Production is fronted by Apache (reverse proxy + TLS) with PM2 managing the Node process. The Apache vhost proxies localhost:3000 and upgrades WebSocket connections via mod_proxy_wstunnel.

Required Apache modules

sudo a2enmod proxy proxy_http proxy_wstunnel rewrite ssl headers sudo systemctl restart apache2

HTTP vhost (port 80)

File: /etc/apache2/sites-available/your-domain.com.conf

<VirtualHost *:80> ServerName your-domain.com ServerAlias www.your-domain.com # Redirect all HTTP traffic to HTTPS RewriteEngine On RewriteCond %{HTTPS} off RewriteRule ^ https://%{HTTP_HOST}%{REQUEST_URI} [L,R=301] ErrorLog ${APACHE_LOG_DIR}/your-domain.com_error.log CustomLog ${APACHE_LOG_DIR}/your-domain.com_access.log combined </VirtualHost>

HTTPS vhost (port 443)

File: /etc/apache2/sites-available/your-domain.com-le-ssl.conf

Certbot usually generates this file for you when you run the TLS step below — use this template as a reference to check or augment it. Note: the WebSocket RewriteRule must appear before the ProxyPass so the upgrade request is handled first.

<IfModule mod_ssl.c> <VirtualHost *:443> ServerName your-domain.com ServerAlias www.your-domain.com ProxyRequests Off ProxyPreserveHost On ProxyVia On <Proxy *> Require all granted </Proxy> # WebSocket upgrade โ€” MUST come before ProxyPass RewriteEngine On RewriteCond %{HTTP:Upgrade} =websocket [NC] RewriteRule /(.*) ws://127.0.0.1:3000/$1 [P,L] # HTTP / long-polling fallback for everything else ProxyPass / http://127.0.0.1:3000/ ProxyPassReverse / http://127.0.0.1:3000/ # SSL โ€” Certbot fills these in SSLCertificateFile /etc/letsencrypt/live/your-domain.com/fullchain.pem SSLCertificateKeyFile /etc/letsencrypt/live/your-domain.com/privkey.pem Include /etc/letsencrypt/options-ssl-apache.conf ErrorLog ${APACHE_LOG_DIR}/your-domain.com_ssl_error.log CustomLog ${APACHE_LOG_DIR}/your-domain.com_ssl_access.log combined </VirtualHost> </IfModule>

TLS certificate

sudo apt install certbot python3-certbot-apache sudo certbot --apache -d your-domain.com -d www.your-domain.com

Enable sites & reload

sudo a2ensite your-domain.com.conf sudo a2ensite your-domain.com-le-ssl.conf sudo apache2ctl configtest sudo systemctl reload apache2

PM2

cd /var/www/websockets sudo pm2 start ecosystem.config.js sudo pm2 save sudo pm2 startup systemd

Heads up: PM2 is per-user. Always manage the production process with sudo pm2 โ€ฆ if you originally started it as root. Running pm2 list as a different user will show a different (possibly empty) list and silently restart the wrong daemon.

Applying new code

cd /var/www/websockets git pull npm install sudo pm2 restart websockets-app --update-env sudo pm2 logs websockets-app --lines 20 --nostream # Verify: curl http://127.0.0.1:3000/health

The --update-env flag makes PM2 re-read the environment so new .env values take effect.

โœ… Production checklist
MySQL reachable ยท apps table populated ยท JWT_SECRET set and stable ยท TLS cert valid ยท proxy_wstunnel enabled ยท PM2 configured to start on boot ยท Cloudflare (if used) has WebSockets enabled in the Network tab
๐Ÿ”ง Troubleshooting
SymptomLikely cause
Cannot GET /health Old code still running. Deploy and restart: git pull && sudo pm2 restart websockets-app --update-env
PM2 restart says "โœ“" but nothing changes Two PM2 daemons (user vs root). Check sudo lsof -i :3000 to see which user owns port 3000.
MySQL connection failed on boot DB_* env vars wrong or MySQL not reachable. Try connecting with the same creds via mysql client.
401 Unauthorized: invalid app key App doesn't exist in the DB this Node reads, or X-App-Key header typo.
401 Unauthorized: invalid app secret Key matches but secret doesn't. Check the manager for the current value (it may have been regenerated).
Client connects but then drops with App key mismatch The token was minted for App A but the client is connecting to App B's namespace.
Client gets Invalid or expired token JWT expired (default 2 h) or JWT_SECRET was changed since the token was minted.
WebSocket falls back to long-polling only mod_proxy_wstunnel not enabled, or Apache config has ProxyPass before the WebSocket rewrite, or Cloudflare "WebSockets" is off.
Origin not allowed Client's origin isn't in the App's allowed_origins. Leave the field empty to allow all.
Broadcast reports success but clients don't receive Channel name mismatch, clients not subscribed to that channel in their JWT, or they're on a different App's namespace.

Useful commands

CommandWhat it does
sudo pm2 listShow running processes under root's PM2
sudo pm2 logs websockets-app --lines 50Recent application logs
sudo lsof -i :3000What's listening on port 3000
curl http://127.0.0.1:3000/healthOrigin-side health check (bypasses Apache and Cloudflare)
curl https://your-domain.com/healthEnd-to-end health check (through the whole stack)
sudo tail -f /var/log/apache2/your-domain_ssl_error.logApache proxy errors
mysql -u root -p -e "USE mywebsockets; SELECT id, \`key\`, enabled FROM apps;"What apps does Node see?
โš ๏ธ Security notes
Never expose APP_SECRET or JWT_SECRET in client-side code. Issue short-lived JWTs from your backend only. Rotate the App secret in the manager if you suspect a leak — existing JWTs remain valid until they expire, but no new ones can be minted with the old secret.

Multi-Tenant WebSocket Server

Node.js ยท Express ยท Socket.IO ยท MySQL