Node-RED

Secure WebSocket push notifications

A Node-RED flow for pushing real-time updates to browser clients over WebSocket, with JWT or HMAC token auth, multi-tab support, and targeted fan-out by user identity.

The problem

Node-RED has built-in WebSocket support - listener nodes, input/output nodes, the works. Getting a message from the server to a browser is trivial. The hard part is everything around it: who is on the other end of that WebSocket? How do you authenticate them? What happens when they open a second tab? How do you push a message to one specific user without broadcasting to everyone?

The tempting shortcut is to put a token in the WebSocket URL query string: ws://host/ws?token=.... Don't. Query strings end up in proxy logs, access logs, and browser history. The token should travel inside the WebSocket, not beside it.

sequenceDiagram participant B as Browser participant P as PHP app (token issuer) participant N as Node-RED participant API as Backend API B->>P: Login (cookie session) P-->>B: Short-lived WS token (JWT/HMAC) B->>N: WebSocket connect /ws/simple B->>N: hello + token N->>N: Verify token, map identity to wsId N-->>B: welcome API->>N: POST /push (X-API-Key, toToken) N->>N: Fan-out to all wsIds for token N-->>B: Pushed message (all tabs)

The secure hello pattern

The flow uses a "secure hello" pattern. The client connects to the WebSocket with no credentials in the URL. Immediately after connecting, it sends a JSON message:

{"type": "hello", "token": "<short-lived-token>"}

The server receives this on a websocket in node and passes it to a function node called "identify & map". That function verifies the token and, if valid, registers the connection as authenticated. If the token is invalid or missing, the function returns null - the connection is silently dropped. No welcome, no error, just silence.

If the token is valid, the function extracts a stable identity from it (a user ID or username), maps that identity to the WebSocket connection ID in flow context, and sends back a {"type": "welcome"} message so the client knows it's connected and authenticated.

const crypto = require('crypto');

function timingSafeEq(a, b) {
  const A = Buffer.from(a || '');
  const B = Buffer.from(b || '');
  if (A.length !== B.length) return false;
  return crypto.timingSafeEqual(A, B);
}

// Verify JWT: split header.payload.signature, re-sign with HMAC-SHA256, compare
function verifyJWT(token, secret) {
  const parts = (token || '').split('.');
  if (parts.length !== 3) return null;
  const [h, p, s] = parts;
  const data = h + '.' + p;
  const expSig = b64u.encode(crypto.createHmac('sha256', secret).update(data).digest());
  if (!timingSafeEq(s, expSig)) return null;
  const payload = JSON.parse(b64u.decode(p));
  const now = Math.floor(Date.now() / 1000);
  if (payload.exp && now > payload.exp) return null;
  return payload;
}

const SECRET = env.get('AUTH_SECRET');
let p = msg.payload;
if (!p || p.type !== "hello" || !p.token || !SECRET) return null;

const claims = verifyJWT(p.token, SECRET);
if (!claims) return null;
const ident = String(claims.sub || claims.uid || '');

// Map identity to array of wsIds (multi-tab)
const wsId = msg._session.id;
const map = flow.get('wsByToken') || {};
const arr = Array.isArray(map[ident]) ? map[ident] : [];
if (!arr.includes(wsId)) arr.push(wsId);
map[ident] = arr;
flow.set('wsByToken', map);

return { _session: msg._session,
         payload: JSON.stringify({ type: 'welcome', wsId, sessionsForIdentity: arr.length }) };

Two token modes: JWT or HMAC

The function supports two token formats, selected by the AUTH_MODE environment variable. Both share a single AUTH_SECRET and both check an exp claim so tokens are short-lived.

In JWT mode, the token is a standard three-part JWT signed with HMAC-SHA256. The function verifies the signature, parses the payload, checks the expiry, and extracts the identity from the sub, uid, or sid claim.

In HMAC mode, the token is simpler: a base64url-encoded JSON payload, a dot, and a base64url-encoded HMAC-SHA256 signature. Same verification logic, same expiry check, same identity extraction. This is useful when you don't need the full JWT header overhead and just want a signed, expiring token.

Both modes use crypto.timingSafeEqual for the signature comparison, so the verification is constant-time. The function requires functionExternalModules: true in Node-RED's settings.js to access Node's crypto module.

Multi-tab support

A user opens a second browser tab. The PHP app issues the same token (or a new one for the same identity). The new tab connects, sends its hello, and gets verified. Now there are two WebSocket connections for the same user.

The flow handles this with a flow-context map called wsByToken. Each token maps to an array of WebSocket connection IDs. When a new connection is authenticated, its ID is pushed onto the array. When a connection closes, its ID is removed. Pushing a message to a user means iterating over their connection IDs and sending to each one.

This is the difference between "push to user X" and "push to the one WebSocket for user X". Real users have multiple tabs. The flow accounts for that.

The /push endpoint

A separate HTTP endpoint, POST /push, lets the backend push messages to connected clients. Authentication is a simple API key checked against the PUSH_API_KEY environment variable via the X-API-Key header.

The push function supports three delivery modes. If the request body contains a toToken field (a string or array), the message is fanned out to every WebSocket connection mapped to that token - all of the user's tabs. If it contains a toWs field, the message goes to one specific WebSocket by ID. If neither is present, the message is broadcast to every connected client.

The response is a JSON object with diagnostics: ok, targeted, delivered, targets (tokens that were online), and missing (tokens that had no connected WebSocket). This lets the caller know whether the push actually reached anyone.

const API_KEY = env.get('PUSH_API_KEY');
const hdr = (msg.req && msg.req.headers) ? msg.req.headers : {};
if (!API_KEY || hdr['x-api-key'] !== API_KEY) {
    msg.statusCode = 401;
    return [null, { payload: { ok: false, error: 'unauthorized' } }];
}

const body = msg.payload || {};
const payload = (body.payload !== undefined) ? body.payload : body;
const map = flow.get('wsByToken') || {};

const mk = (wsId) => ({ payload, _session: { type: 'websocket', id: wsId } });
let sendMsgs = [];

if (body.toToken !== undefined) {
  // Fan-out: send to every wsId mapped to this token (all of the user's tabs)
  const tokens = Array.isArray(body.toToken) ? body.toToken.map(String) : [String(body.toToken)];
  for (const t of tokens) {
    const ids = Array.isArray(map[t]) ? map[t] : [];
    for (const id of ids) sendMsgs.push(mk(id));
  }
} else if (body.toWs) {
  // Direct: one specific WebSocket
  sendMsgs.push(mk(String(body.toWs)));
} else {
  // Broadcast: no _session means all clients
  return [{ payload }, { payload: { ok: true, targeted: false, delivered: 0 } }];
}

const delivered = sendMsgs.length;
return [sendMsgs, { payload: { ok: delivered > 0, targeted: true, delivered } }];

The token issuer

The WebSocket flow doesn't issue tokens itself. That's the job of an external PHP application. The PHP app authenticates the user with a traditional cookie session, then issues a short-lived token (JWT or HMAC, signed with the same AUTH_SECRET) that the browser uses for WebSocket authentication.

This separation is deliberate. The PHP app owns the session, the login form, the password check. Node-RED owns the real-time transport. The token is the handoff - short-lived, signed, and carrying just enough identity (a user ID) to route WebSocket messages.

The /simple demo page

The flow includes a GET /simple endpoint that serves a minimal HTML page demonstrating the full cycle. The page's JavaScript fetches a token from the WS_TOKEN_URL endpoint (configured via environment variable, defaulting to /ws-token on the PHP app), using credentials: 'include' so the cookie session travels with the request.

Once it has the token, it opens the WebSocket, sends the hello, waits for the welcome, and then displays any pushed messages. The token can come back from the PHP app as JSON ({"token": "..."}) or as an X-WS-Token response header - the page handles both.

It's a demo, but it's a working end-to-end test: cookie auth, token issuance, WebSocket connection, secure hello, and message receipt.

Import the flow

The complete flow - websocket listener, /push endpoint, /simple demo page, and the websocket-listener config node - is available as a sanitised JSON file:

Download nodered-websocket-push.json

In Node-RED: hamburger menu → Import → select the file. After importing, set the AUTH_SECRET, AUTH_MODE (jwt or hmac), and PUSH_API_KEY environment variables on the flow tab (or in Node-RED's environment), and point WS_TOKEN_URL at your token-issuing endpoint.

At a glance
  • Transport: WebSocket
  • Token auth: JWT or HMAC (timing-safe)
  • Multi-tab: yes (wsByToken map)
  • Endpoints: /ws/simple, POST /push, GET /simple
  • Token issuer: external PHP app (cookie session)
Need real-time push?
I do contract solutions architecture and can help design event-driven automation.
Contact me