Signing WebSocket messages with HMAC #

Your WebSocket traffic does not always travel straight from an authenticated browser to your server. Messages from a trading bot pass through a relay, commands to IoT devices sit in a queue before a gateway forwards them, and events from one internal service are fanned out to another through a shared broker. TLS protects each hop, but it does not tell the final recipient that a message was produced by who it claims to be, or that it has not been replayed from a log an hour later. When a message authorizes an action — move money, unlock a door, change a configuration — the message itself needs to carry proof. That proof is a per-message signature, and HMAC is the simplest correct way to produce one.

Root cause #

Connection-level authentication binds a socket to an identity. Everything received on that socket is attributed to that identity, which is correct as long as the socket is the whole path. It stops being correct when messages are relayed: the recipient’s socket belongs to the relay, not the originator, so connection auth tells you only that the relay sent it. A compromised or buggy relay, a misrouted queue, or an operator replaying messages from a dead-letter queue can all produce frames that the recipient will trust.

Replay is the subtler threat. Even over a direct, authenticated socket, a captured valid message — from a log, a debugging proxy, a browser extension — can be re-sent later. A signature alone does not prevent that, because the replayed message is genuinely signed. Preventing it requires the signed content to include something that makes each message unique and fresh: a nonce and a timestamp, checked against a window and a seen-set on the receiver.

What protects what Four layers of protection: TLS per hop, connection authentication per socket, and an HMAC signature plus timestamp and nonce per message. What protects what TLS (wss://) confidentiality and integrity per hop; ends at every relay per hop Connection auth binds a socket to the immediate sender, e.g. the relay per socket HMAC signature binds the message body to the originator's key per message Timestamp + nonce binds the signature to one moment, blocking replay per message Only the bottom two layers survive being passed through a relay or queue
Signatures and nonces travel with the message; TLS and connection auth do not.

Resolution #

Sign a canonical encoding of the message envelope with HMAC-SHA256, using a key identified by kid so you can rotate keys without downtime. Include a timestamp and a random nonce inside the signed content. On receipt, reject messages outside a clock-skew window, reject nonces seen within that window, and compare signatures in constant time.

import { createHmac, randomBytes, timingSafeEqual } from 'node:crypto';

const MAX_SKEW_MS = 30_000; // accept messages up to 30 s old or early
const NONCE_BYTES = 16;

type Keyring = Map<string, Buffer>; // kid -> secret; holds current + previous key

interface SignedEnvelope {
kid: string; // which key signed this
ts: number; // sender clock, ms since epoch
nonce: string; // unique per message
type: string;
payload: unknown;
sig: string; // base64url HMAC over the canonical form of everything above
}

// Canonical form: fixed field order, no whitespace. Never sign JSON.stringify of an
// arbitrary object — key order is not guaranteed across producers.
function canonical(e: Omit<SignedEnvelope, 'sig'>): string {
return JSON.stringify([e.kid, e.ts, e.nonce, e.type, e.payload]);
}

export function sign(keys: Keyring, kid: string, type: string, payload: unknown): SignedEnvelope {
const body = { kid, ts: Date.now(), nonce: randomBytes(NONCE_BYTES).toString('base64url'), type, payload };
const sig = createHmac('sha256', keys.get(kid)!).update(canonical(body)).digest('base64url');
return { ...body, sig };
}

export class Verifier {
// Nonces seen inside the skew window; entries older than the window are pruned.
private seen = new Map<string, number>();

constructor(private keys: Keyring) {}

verify(e: SignedEnvelope, now = Date.now()): { ok: true } | { ok: false; reason: string } {
const key = this.keys.get(e.kid);
if (!key) return { ok: false, reason: 'unknown_kid' };
if (Math.abs(now - e.ts) > MAX_SKEW_MS) return { ok: false, reason: 'stale_or_future' };

const expected = createHmac('sha256', key).update(canonical(e)).digest();
const given = Buffer.from(e.sig, 'base64url');
// Constant-time compare; length check first because timingSafeEqual throws on mismatch.
if (given.length !== expected.length || !timingSafeEqual(given, expected)) {
return { ok: false, reason: 'bad_signature' };
}
if (this.seen.has(e.nonce)) return { ok: false, reason: 'replay' };
this.seen.set(e.nonce, e.ts);
this.prune(now);
return { ok: true };
}

private prune(now: number) {
for (const [nonce, ts] of this.seen) {
if (now - ts > MAX_SKEW_MS) this.seen.delete(nonce);
else break; // Map is insertion-ordered: the rest are newer
}
}
}

Check the signature before recording the nonce, otherwise an attacker can fill your seen-set with garbage nonces. The seen-set only needs to cover the skew window, because anything older is already rejected by the timestamp check; that keeps its memory bounded by message rate × window. When several server instances receive the same stream, keep the seen-set in Redis with SET nonce 1 NX PX 30000 so a replay sent to a different instance is caught too — the same idea used in idempotent WebSocket message processing.

Key rotation uses the kid. Add the new key to every verifier’s keyring, then switch signers to it, then remove the old key after the skew window has passed. At no point does a valid message fail verification.

Replay blocked at the receiver An originator signs a message that a relay forwards; the receiver accepts it once, rejects the same frame re-sent later because its nonce was seen, and rejects an altered payload because the signature no longer matches. Replay blocked at the receiver Originator Relay / queue Receiver signed: ts, nonce N1 forwarded sig ok, N1 recorded same frame re-sent later N1 seen → reject replay payload altered sig mismatch → reject The relay never holds the key, so it can forward messages but not forge or modify them
Signatures stop tampering; nonces and timestamps stop replay.

When HMAC is the wrong tool #

HMAC uses a shared secret, so anyone who can verify can also sign. That is fine between services you operate, and for a device that shares a per-device secret with your backend. It is wrong when the verifier must not be able to produce messages — for example, if browsers verify server announcements, since any secret shipped to a browser is public. Use asymmetric signatures (Ed25519 via crypto.sign) there: the server signs with a private key, clients verify with the public one. The envelope, timestamp and nonce logic is identical; only the primitive changes.

Do not sign every message in a browser-to-server chat either. The browser’s session is already authenticated at the upgrade, there is no relay, and the only party who could replay messages is the user themselves. Signatures earn their cost when messages authorize actions and pass through intermediaries.

Choosing a message signature Whether HMAC, Ed25519 or no per-message signature fits service-to-service, device, server-to-browser and browser-to-server chat traffic. Choosing a message signature HMAC-SHA256 Ed25519 None Service to service good fit works only if direct Device with secret good fit better if keys leak risky Server to browser secret exposed good fit usually fine Browser chat to server unnecessary unnecessary session auth suffices Symmetric keys mean every verifier is also a potential signer
HMAC for parties that can share a secret; public-key signatures when they cannot.

Verification #

Unit-test the three rejection paths explicitly, because a verifier that returns ok for everything passes every happy-path test:

it('rejects tampering, replay and stale messages', () => {
const keys = new Map([['k1', Buffer.alloc(32, 7)]]);
const v = new Verifier(keys);
const msg = sign(keys, 'k1', 'transfer', { amount: 100 });
expect(v.verify(msg).ok).toBe(true);
expect(v.verify(msg)).toEqual({ ok: false, reason: 'replay' });
expect(v.verify({ ...sign(keys, 'k1', 'transfer', { amount: 100 }), payload: { amount: 9999 } }))
.toEqual({ ok: false, reason: 'bad_signature' });
expect(v.verify(sign(keys, 'k1', 'x', {}), Date.now() + 120_000))
.toEqual({ ok: false, reason: 'stale_or_future' });
});

In production, count rejections by reason. stale_or_future in volume usually means a clock is wrong — check NTP on the producer. replay spikes point at a retrying relay or a replayed queue. bad_signature should be near zero; anything else is either a canonicalization bug or someone tampering.

Operational checklist #

FAQ #

Doesn’t WSS already guarantee message integrity? #

WSS protects the bytes between two TLS endpoints. Every relay, proxy or queue decrypts and re-encrypts, so the recipient only knows the last hop was honest. A message signature survives any number of hops.

Why not just sign the raw JSON string? #

You can, if the exact bytes are preserved end to end. Relays often parse and re-serialize JSON, which can reorder keys or change number formatting and break the signature. Signing a canonical encoding avoids that; alternatively, carry the payload as an opaque string and sign those bytes.

How big should the skew window be? #

Large enough to absorb clock drift and queueing delay on legitimate paths, small enough to limit how long a captured message stays replayable and how large the nonce set grows. Thirty seconds suits direct and relayed real-time traffic; queued command paths may need minutes.

Can I use the JWT from the upgrade to sign messages? #

No — a JWT is a bearer token, not a signing key, and exposing it in every message widens its exposure. Derive a per-session signing key on the server if you need per-session signatures, or use the patterns in rotating WebSocket tokens without dropping connections for credential freshness.

Back to WebSocket Authentication & Authorization.