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.
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.
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.
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.
Related #
- Validating JWT on the WebSocket Upgrade — connection-level identity.
- Idempotent WebSocket Message Processing — the dedup store the nonce check reuses.
- Designing a WebSocket Message Envelope — where signature fields belong.
- Mutual TLS for WebSocket Clients — certificate identity for devices and services.