DevLift
Back to Blog

Eleven Bytes: Reading a WebSocket Off the Wire

A WebSocket message is eleven bytes on the wire, and almost everything written about those bytes is checkable against a real socket in about a second.

Admin
August 10, 202611 min read69 views
Eleven Bytes: Reading a WebSocket Off the Wire

Eleven Bytes: Reading a WebSocket Off the Wire

00000000  81 85 37 fa 21 3d 7f 9f 4d 51 58                 |..7.!=..MQX|

That is a complete WebSocket message. Five bytes of payload, six bytes of header, no Content-Type, no Cookie, no 200 OK. It says "Hello". The same string over an HTTP poll from a real browser costs 879 bytes on the wire, which I measured rather than guessed, and I will show you both captures.

Everything below ran on localhost against Node v22.22.3 and ws@8.21.3. Every hexdump is captured output, not an illustration.

The handshake is one SHA-1, and it is not about caching

A WebSocket starts as an ordinary HTTP/1.1 GET. Here is the request my raw client sent and the response a Node server sent back, printed straight from the socket with \r\n shown as a pilcrow:

--- request ---
GET /chat HTTP/1.1⏎
Host: localhost:8080⏎
Upgrade: websocket⏎
Connection: Upgrade⏎
Sec-WebSocket-Key: 2njpx9pIbgjoDV5+BcJl5w==⏎
Sec-WebSocket-Version: 13⏎

--- response ---
HTTP/1.1 101 Switching Protocols⏎
Upgrade: websocket⏎
Connection: Upgrade⏎
Sec-WebSocket-Accept: cq1mM3DpAOOPJv91/ewhVpFVWP0=⏎

accept matches: true
has Content-Length: false

Status 101, Connection: Upgrade, and no Content-Length — because there is no body, and after the blank line the bytes stop being HTTP entirely. The whole server side of that exchange is a hash:

// accept-key.mjs
import { createHash } from 'node:crypto';
 
const assert = (cond, msg) => { if (!cond) throw new Error(msg); };
 
// RFC 6455 section 1.3
const WS_GUID = '258EAFA5-E914-47DA-95CA-C5AB0DC85B11';
 
export const acceptFor = (key) =>
  createHash('sha1').update(key + WS_GUID).digest('base64');
 
// The worked example in RFC 6455 section 1.3
const rfcKey = 'dGhlIHNhbXBsZSBub25jZQ==';
assert(Buffer.from(rfcKey, 'base64').length === 16, 'the key is a 16-byte nonce, base64-encoded');
assert(acceptFor(rfcKey) === 's3pPLMBiTxaQ9kYGzzhZRbK+xOo=', 'accept value must match RFC 6455 1.3');

Both asserts pass. The intermediate SHA-1 is b37a4f2cc0624f1690f64606cf385945b2bec4ea, byte-for-byte the value RFC 6455 prints, and dGhlIHNhbXBsZSBub25jZQ== decodes to the ASCII string the sample nonce.

⚠️

You will read that Sec-WebSocket-Key exists to stop caching proxies from serving your private chat messages to the next visitor. That is the justification for masking, not for the key. RFC 6455 section 1.3 states the actual reason: "the server has to prove to the client that it received the client's WebSocket handshake, so that the server doesn't accept connections that are not WebSocket connections. This prevents an attacker from tricking a WebSocket server by sending it carefully crafted packets using XMLHttpRequest or a form submission." It is a cross-protocol defence. A <form> post cannot produce that header, so it cannot produce that response.

Rendering diagram...

The frame header, and the bit everyone gets wrong

Two bytes minimum. Byte 0 is FIN | RSV1 | RSV2 | RSV3 | opcode(4). Byte 1 is MASK | length(7). Opcodes worth memorising: 0x0 continuation, 0x1 text, 0x2 binary, 0x8 close, 0x9 ping, 0xa pong. RSV1 is not always zero — permessage-deflate sets it, and I will show that frame later.

The length field is the part that bites. Seven bits, or 7+16, or 7+64:

  • 0–125 is the length itself.
  • 126 means read the next 2 bytes as a big-endian uint16, so up to 65,535 bytes.
  • 127 means read the next 8 bytes as a big-endian uint64.

That last case gets quoted as "18 exabytes". It is not. RFC 6455 section 5.2 says the 8 bytes are "a 64-bit unsigned integer (the most significant bit MUST be 0)", capping a frame at 2^63 - 1, about 9.2 exabytes. Half the number you were told, and a parser that skips the rule accepts lengths no conformant peer will send.

Here is a codec that encodes and decodes the RFC's own test vector, and — the part that matters — returns null when it does not yet have a whole frame:

// frame.mjs
const assertFrame = (cond, msg) => { if (!cond) throw new Error(msg); };
 
export function hexdump(buf) {
  const out = [];
  for (let i = 0; i < buf.length; i += 16) {
    const row = buf.subarray(i, i + 16);
    const hex = [...row].map((b) => b.toString(16).padStart(2, '0')).join(' ').padEnd(47);
    const txt = [...row].map((b) => (b > 31 && b < 127 ? String.fromCharCode(b) : '.')).join('');
    out.push(`${i.toString(16).padStart(8, '0')}  ${hex}  |${txt}|`);
  }
  return out.join('\n');
}
 
export function encodeFrame({ opcode = 0x1, payload = Buffer.alloc(0), fin = true, maskKey = null }) {
  const b0 = (fin ? 0x80 : 0) | opcode;
  const n = payload.length;
  let header;
  if (n < 126) {
    header = Buffer.from([b0, (maskKey ? 0x80 : 0) | n]);
  } else if (n < 65536) {
    header = Buffer.alloc(4);
    header[0] = b0; header[1] = (maskKey ? 0x80 : 0) | 126; header.writeUInt16BE(n, 2);
  } else {
    header = Buffer.alloc(10);
    header[0] = b0; header[1] = (maskKey ? 0x80 : 0) | 127; header.writeBigUInt64BE(BigInt(n), 2);
  }
  if (!maskKey) return Buffer.concat([header, payload]);
  const masked = Buffer.from(payload);
  for (let i = 0; i < masked.length; i++) masked[i] ^= maskKey[i % 4];
  return Buffer.concat([header, maskKey, masked]);
}
 
// Returns null when `buf` does not yet hold a whole frame. That return value is
// the entire point: TCP hands you arbitrary slices, not messages.
export function decodeFrame(buf) {
  if (buf.length < 2) return null;
  const fin = (buf[0] & 0x80) !== 0;
  const rsv1 = (buf[0] & 0x40) !== 0;
  const opcode = buf[0] & 0x0f;
  const isMasked = (buf[1] & 0x80) !== 0;
  let len = buf[1] & 0x7f;
  let off = 2;
  if (len === 126) {
    if (buf.length < 4) return null;
    len = buf.readUInt16BE(2); off = 4;
  } else if (len === 127) {
    if (buf.length < 10) return null;
    const big = buf.readBigUInt64BE(2);
    if (big > 0x7fffffffffffffffn) throw new Error('RFC 6455 5.2: the high bit of a 64-bit length MUST be 0');
    len = Number(big); off = 10;
  }
  let maskKey = null;
  if (isMasked) {
    if (buf.length < off + 4) return null;
    maskKey = buf.subarray(off, off + 4); off += 4;
  }
  if (buf.length < off + len) return null;
  const payload = Buffer.from(buf.subarray(off, off + len));
  if (maskKey) for (let i = 0; i < payload.length; i++) payload[i] ^= maskKey[i % 4];
  return { fin, rsv1, opcode, isMasked, maskKey, payload, frameLength: off + len, headerLength: off };
}
 
// The single-frame masked "Hello" from RFC 6455 section 5.7
const vector = encodeFrame({ payload: Buffer.from('Hello'), maskKey: Buffer.from([0x37, 0xfa, 0x21, 0x3d]) });
assertFrame(vector.toString('hex') === '818537fa213d7f9f4d5158', 'must match RFC 6455 5.7 masked example');
assertFrame(decodeFrame(vector).payload.toString() === 'Hello', 'round-trip must unmask back to Hello');
assertFrame(decodeFrame(vector.subarray(0, 7)) === null, 'a partial frame must decode to null');

Decoded, those eleven bytes are FIN=1 RSV1=0 opcode=0x1 MASK=1 len=5 key=37fa213d, payload "Hello". The direction of that MASK bit is not a style choice. RFC 6455 section 5.1: "A server MUST NOT mask any frames that it sends to the client. A client MUST close a connection if it detects a masked frame." Client to server, always masked. Server to client, never. The reason is in section 10.3, and it is a real historical attack: an experiment "demonstrate[d] a class of attacks on proxies that led to the poisoning of caching proxies deployed in the wild", so "the defense adopted is to mask all data from the client to the server, so that the remote script (attacker) does not have control over how the data being sent appears on the wire". Masking is not encryption. It is a guarantee that a script cannot choose the bytes.

Where the from-scratch server in every tutorial breaks

The canonical teaching server reads a two-byte header (widening to four when byte 1 says 126), slices the mask off, XORs, echoes. I ran one against a raw client and against ws@8. Five failures, each reproducible in under a second.

One TCP read is not one frame. I wrote two frames in a single write(). The server echoed seven bytes — 81 05 66 69 72 73 74, just "first". The second message was silently discarded.

One frame is not one TCP read. I split a 24-byte frame after 8 bytes. The server parsed the first slice as a whole frame and the remainder as a new one:

server echoed 26 bytes: "spõVZܹXM«H½\\R¶OL"

That is your JSON parser throwing at 3am on a message nobody can reproduce.

A ping is not data. I sent opcode 0x9. The server echoed it back as opcode 0x1, text. RFC 6455 section 5.5.2 is explicit: "Upon receipt of a Ping frame, an endpoint MUST send a Pong frame in response, unless it already received a Close frame."

Close is a handshake, not a hangup. I sent 0x8 with code 1000. The server sent zero bytes back and called socket.end(). No Close frame, ever.

The echo path corrupts itself past 125 bytes. writeUInt8(payload.length, 1) for a 200-byte message writes 0xc8, which sets the MASK bit and declares a length of 72. A real client refuses immediately:

A: ws client ERROR -> Invalid WebSocket frame: MASK must be clear

At 300 bytes it stops being a protocol error and becomes an outage:

RangeError [ERR_OUT_OF_RANGE]: The value of "value" is out of range.
    It must be >= 0 and <= 255. Received 300
    at Buffer.writeUInt8 (node:internal/buffer:750:10)

That is an unhandled throw inside a data handler. It takes the process down, and with it every other connection on that box. One 300-byte chat message.

A server that survives contact

Same shape, five holes closed: a pending buffer across reads, control frames handled before data, fragments reassembled, unmasked frames rejected, a size cap.

// server.mjs
import http from 'node:http';
import { acceptFor } from './accept-key.mjs';
import { encodeFrame, decodeFrame } from './frame.mjs';
 
const MAX_MESSAGE = 1 << 20; // 1 MiB. Pick a number; "unbounded" is not a number.
 
const server = http.createServer((req, res) => res.end('speak websocket to me\n'));
 
server.on('upgrade', (req, socket) => {
  const key = req.headers['sec-websocket-key'];
  if (req.headers.upgrade?.toLowerCase() !== 'websocket' || !key) {
    socket.end('HTTP/1.1 400 Bad Request\r\nConnection: close\r\nContent-Length: 0\r\n\r\n');
    return;
  }
  socket.write(
    'HTTP/1.1 101 Switching Protocols\r\n' +
    'Upgrade: websocket\r\n' +
    'Connection: Upgrade\r\n' +
    `Sec-WebSocket-Accept: ${acceptFor(key)}\r\n\r\n`
  );
 
  let pending = Buffer.alloc(0);   // bytes TCP has handed us but we cannot parse yet
  let fragments = [];              // an in-progress fragmented message
  let fragmentOpcode = null;
  let closing = false;
 
  const send = (opcode, payload) => {
    if (!socket.writableEnded) socket.write(encodeFrame({ opcode, payload })); // never masked
  };
  const fail = (code, reason) => {
    closing = true;
    const body = Buffer.alloc(2 + Buffer.byteLength(reason));
    body.writeUInt16BE(code, 0);
    body.write(reason, 2);
    send(0x8, body);
    socket.end();
  };
 
  socket.on('data', (chunk) => {
    pending = Buffer.concat([pending, chunk]);
    for (;;) {
      let frame;
      try { frame = decodeFrame(pending); } catch { return fail(1002, 'bad length'); }
      if (!frame) return;                       // incomplete: wait for more TCP
      pending = pending.subarray(frame.frameLength);
 
      if (!frame.isMasked) return fail(1002, 'client frames must be masked'); // RFC 6455 5.1
      if (frame.opcode >= 0x8) {
        if (!frame.fin || frame.payload.length > 125) return fail(1002, 'bad control frame');
        if (frame.opcode === 0x9) { send(0xa, frame.payload); continue; }    // ping -> pong
        if (frame.opcode === 0xa) continue;                                  // pong bookkeeping
        if (frame.opcode === 0x8) {
          if (!closing) send(0x8, frame.payload.subarray(0, 2));             // echo the code
          closing = true;
          socket.end();
          return;
        }
      }
 
      if (frame.opcode === 0x0 && fragmentOpcode === null) return fail(1002, 'continuation without start');
      if (frame.opcode !== 0x0) fragmentOpcode = frame.opcode;
      fragments.push(frame.payload);
      const total = fragments.reduce((n, b) => n + b.length, 0);
      if (total > MAX_MESSAGE) return fail(1009, 'message too big');
      if (!frame.fin) continue;
 
      const message = Buffer.concat(fragments);
      fragments = []; const op = fragmentOpcode; fragmentOpcode = null;
      send(op, message);                        // echo, with a correct extended length
    }
  });
 
  socket.on('error', () => socket.destroy());
});
 
server.listen(8080, () => console.log('ws://localhost:8080'));

Driving it with a raw client that deliberately packs a ping and both halves of a fragmented message into one write():

server frame: opcode=0xa MASK=0 len=2 header=2B
00000000  8a 02 68 62                                      |..hb|
 
server frame: opcode=0x1 MASK=0 len=11 header=2B
00000000  81 0b 66 72 61 67 2d 6d 65 6e 74 65 64           |..frag-mented|
 
server frame: opcode=0x1 MASK=0 len=200 header=4B
00000000  81 7e 00 c8 41 41 41 41 41 41 41 41 41 41 41 41  |.~..AAAAAAAAAAAA|
...12 more rows of 41...
 
server frame: opcode=0x8 MASK=0 len=2 header=2B
00000000  88 02 03 e8                                      |....|
close code echoed by server: 1000

Read 81 7e 00 c8: FIN set, text, MASK clear, 0x7e = 126 meaning "extended", then 0x00c8 = 200. That is the 7+16 encoding on a real wire, and 88 02 03 e8 is a Close frame carrying 0x03e8 = 1000. Pointing ws@8 at the same server, payloads of 5, 200, 70,000 and 300,000 bytes all echo back at the right length, and client.ping('alive?') returns a pong with the payload "alive?", as section 5.5.3 requires.

879 bytes to move 17

Numbers, measured through a byte-counting TCP proxy sitting between client and server. Payload is a 17-byte JSON tick, {"p":42.17,"t":9}, 100 exchanges, one TCP connection each way.

transporttotal bytesper message
HTTP/1.1 keep-alive polling29,000290 B
WebSocket4,49645 B
WebSocket + permessage-deflate2,04420 B

The WebSocket figures include the whole handshake amortised over 100 messages; steady state is 6 header bytes up and 2 down, so 42 bytes per round trip. The 290-byte HTTP figure is generous to HTTP, because Node's client sends no cookies and no User-Agent. The same poll from a logged-in Chrome tab — session cookie, sec-ch-ua, Accept-Encoding, Referer — measured 688 bytes of request against a 191-byte response. 879 bytes to move 17.

Latency is a different question from bytes, and it has a smaller answer. Each figure below is the median of 1,000 sequential round trips over loopback, client and server in the same Node process on a 4-core arm64 Linux container:

WebSocket send -> echo         : 0.054 ms
HTTP/1.1 keep-alive req -> res : 0.093 ms
HTTP/1.1 new TCP conn each     : 0.238 ms

Those three are themselves medians of eleven runs of that benchmark. Individual runs landed between 0.054 and 0.057, between 0.091 and 0.096, and between 0.236 and 0.240 ms, so the ordering is settled even if the third decimal is not. Per round trip the WebSocket beat keep-alive HTTP by 1.6x to 1.8x across those runs — call it 1.7x — and beat a fresh TCP connection per message by 4.4x.

Notice how much smaller 1.7x is than the 6.4x in the bytes table a paragraph up. They are not the same claim, and "WebSockets are faster" usually turns out to mean the bytes one. Loopback also has no TLS handshake, no congestion window and no proxy hop — costs the third row would pay on every single message over a real network, and the first two rows pay once.

The two failures the protocol will not report

A dead peer looks exactly like a quiet one. I put a TCP proxy between client and server and made it stop forwarding without closing either socket, which is what a NAT box or load balancer does when it evicts an idle flow. Nginx's proxy_read_timeout defaults to 60s; an AWS Application Load Balancer's idle_timeout.timeout_seconds defaults to 60 seconds. Neither sends you anything.

== no heartbeat ==
  t+0.5s middlebox goes silent (both TCP sockets still open, no FIN, no RST)
  t+8.6s verdict: NONE - server still believes the client is connected; sockets OPEN = 1
 
== ping/pong heartbeat, 1s interval ==
  t+9.4s middlebox goes silent
  t+10.9s verdict: no pong within the window -> terminated

The heartbeat run uses a one-second interval so the test finishes while you are still watching it. The hub.mjs below ships 15 seconds, which is the number you want in production and which would have made that second block take half a minute: detection costs you between one and two intervals, always.

Without a heartbeat the server held that connection open indefinitely and kept writing app data into a void, with no error. Note the asymmetry: the browser WebSocket API cannot help you here. Its whole IDL in the WebSockets Standard is send(), close(), url, readyState, bufferedAmount, extensions, protocol, binaryType, the four CONNECTING/OPEN/CLOSING/ CLOSED constants and the four event handlers. There is no ping(). The browser answers pings below the JavaScript layer; it cannot originate one. If you want the client to detect a dead server, that is an application-level message you write yourself.

send() lies about delivery. It queues. I paused one client's socket and kept publishing 64 KiB messages for three seconds:

sent 41071 distinct 64 KiB messages in 3s to a paused reader
bufferedAmount = 2564.8 MiB   readyState OPEN = true
RSS 66.6 -> 2652.5 MiB  (+2585.9 MiB in 3 s)

2.5 GB of resident memory, from one slow consumer, in three seconds. send() never threw and readyState never left OPEN. Both fixes are about ten lines:

// hub.mjs
import { WebSocketServer, WebSocket } from 'ws';
 
export const hub = new WebSocketServer({ port: 8081 });
const HIGH_WATER = 4 * 1024 * 1024; // bytes we will hold for one slow client
 
hub.on('connection', (client) => {
  client.isAlive = true;
  client.on('pong', () => { client.isAlive = true; });
 
  const heartbeat = setInterval(() => {
    if (!client.isAlive) return client.terminate(); // no pong since the last tick: it is gone
    client.isAlive = false;
    client.ping();
  }, 15_000);
 
  client.on('close', () => clearInterval(heartbeat));
});
 
export function publish(client, data) {
  if (client.readyState !== WebSocket.OPEN) return false;
  if (client.bufferedAmount > HIGH_WATER) {   // the kernel and ws are both full
    client.close(1013, 'too slow');           // 1013 Try Again Later, IANA close-code registry
    return false;
  }
  client.send(data);
  return true;
}

Measured: publish() accepted 104 messages, 6.5 MiB, before the guard fired at a bufferedAmount of 4.0 MiB — the gap is the kernel send buffer.

Closing on purpose

Close codes live in RFC 6455 section 7.4.1, and two of them are traps. 1005 and 1006 are what your onclose handler reports; they are not things you send. The RFC: "1006 is a reserved value and MUST NOT be set as a status code in a Close control frame by an endpoint." Any article that offers 1006 as an example close-frame payload has never sent one. ws@8 refuses outright:

close(1006): REJECTED -> TypeError: First argument must be a valid error code number
close(1002): accepted by ws@8

The browser is stricter still. Per the WebSockets Standard, close(code) throws InvalidAccessError unless code is 1000 or in the range 3000 to 4999 — so a page cannot send 1001, 1008 or 1011 either. Your application codes go in 4000–4999. And a bare close() with no arguments sends a Close frame with an empty payload, which I captured as 88 80 12 8e 55 63: opcode 0x8, MASK set, length 0. No status code on the wire at all.

A client to server frame arrives with byte 1 equal to 0x7e. How many more bytes must you read before you know the payload length?

Your Node server holds 5,000 WebSocket connections. One client's phone goes through a tunnel and its TCP flow is silently dropped by a NAT box. What does the server observe?

🚨

The upgrade request is not subject to CORS. There is no preflight and no Access-Control-Allow-Origin to satisfy, so a page on any domain can open a socket to your server; the Origin header is sent, and checking it is entirely your job. What is no longer true is the scarier half of that warning. A cross-site handshake is not a top-level navigation, and cookies default to Lax enforcement, which per draft-ietf-httpbis-rfc6265bis is sent "with same-site requests, and with cross-site top-level navigations". So a drive-by socket carries your session cookie only if that cookie is explicitly SameSite=None; Secure. Check Origin anyway, and authenticate with a token you verify in the upgrade handler rather than with ambient cookies.

What changed since most WebSocket tutorials were written

  • "WebSockets do not work over HTTP/2." RFC 8441, Bootstrapping WebSockets with HTTP/2, published September 2018, "defines a mechanism for running the WebSocket Protocol (RFC 6455) over a single stream of an HTTP/2 connection". The GET plus Upgrade handshake is replaced by an extended CONNECT carrying a :protocol pseudo-header set to websocket. RFC 9220 does the same for HTTP/3.
  • "There is no compression." RFC 7692 defines permessage-deflate, negotiated in the handshake via Sec-WebSocket-Extensions and flagged per frame by RSV1. A 579-byte presence payload came back as a 116-byte frame in my capture, starting c1 72: FIN set, RSV1 set, opcode text.
  • "Socket.io is WebSockets." It is not, but the usual explanation of why is also wrong. EIO=4 is a query parameter the client puts in its request line, not a payload the server returns. I pointed a socket.io client at a plain server and captured GET /socket.io/?EIO=4&transport=websocket HTTP/1.1. Against a bare ws server the RFC 6455 handshake actually succeeds — the connection then dies with connect_error: timeout, because the Engine.IO layer is waiting for an OPEN packet that never comes.
  • "Discord scales WebSockets with a Redis pub/sub backplane." Not according to Discord. How Discord Scaled Elixir to 5,000,000 Concurrent Users describes BEAM processes, not an external bus: "Users connect to a WebSocket and spin up a session process (a GenServer), which then communicates with remote Erlang nodes that contain guild (internal for a 'Discord Server') processes (also GenServers). When anything is published in a guild, it is fanned out to every session connected to it." The word Redis does not appear on that page. A Redis or Kafka fan-out is a perfectly good design — just do not put Discord's name on it.

The last four bytes

Almost nothing in this article breaks loudly. A frame boundary that does not line up with a TCP read hands your JSON parser a slice of somebody's masked payload at 3am. A ping answered as text does not throw; it just quietly means your liveness check is measuring nothing. A middlebox that stops forwarding leaves readyState at OPEN for as long as you care to wait. A slow reader turns send() into an unbounded queue that will exhaust the box before anything in the API changes value. Of the five, only writeUInt8(300, 1) announces itself — and it announces itself by taking down every other connection on the process.

So the checks worth writing are the ones that turn silence into a signal. On the framing side that means buffering across reads instead of trusting a chunk boundary, and dispatching on the control opcodes before you ever look at a text frame. On the liveness side it means a ping on a timer with a terminate on the missed pong, and a bufferedAmount read before every send. The size cap and the Origin check are not the protocol's job and never will be — a cap you chose is a decision, and no cap is not a decision at all.

The last thing the hardened server wrote to that socket was this:

00000000  88 02 03 e8                                      |....|

Opcode 0x8, two bytes of payload, 0x03e8 = 1000. A close frame carrying a reason, sent in reply to the client's close frame, before either end went near TCP. Four bytes. The tutorial server from the top of this post sends none of them — it calls socket.end() and lets the FIN do the talking, and the peer never learns why.

Comments (0)

No comments yet. Be the first to share your thoughts!

Related Articles

The Garbage Collector Bills You for Survivors, Not Garbage
Five runs of the same one-million-allocation loop on Node 22, changing only how many objects stay reachable, move total GC time from 13 ms to 334 ms — and that single fact explains most of what people get wrong about V8's heap, Go's missing generations, and why Twitch's 10 GiB of useless memory made their API faster.
AdminAugust 10, 202612 min read
How JWT Works: Tokens, Claims, and Signatures
Take a JSON Web Token apart segment by segment, build a signer and verifier with nothing but node:crypto, then run the alg:none and RS256-to-HS256 key-confusion attacks against both your own code and jose@6.2.8 to see exactly which one still forges an admin token.
AdminAugust 6, 202611 min read
Client-side validation is UX. Server-side validation is security. And a schema is not an auth guard — here's the fix that still ships an account takeover, and how to catch it in review.
AdminAugust 5, 20269 min read