16 — WebSocket session authentication¶
Descriptive reference. This section describes SESSION behaviour on the WebSocket transport
— who a node will talk to, and when. It is not part of the normative wire protocol: nothing
here changes a TLV, a frame layout or a codec, and an implementation that never
authenticates is still conformant. The normative surface is ../spec/v1.md.
The problem: a browser cannot present a header¶
A libtracer node reachable over WebSocket has two natural places to decide whether it will serve a peer, and until now both were before the HTTP 101:
a token header on the opening GET, which is what a native dialer (board-to-board, a CLI, a gateway) sends;
a cookie, which is what a browser can be made to send — but only if some other HTTP endpoint minted it first.
The browser WebSocket API cannot set request headers. There is no argument for them
and no interception point. So a browser client cannot present a token at the handshake at
all, which leaves an embedder two options, both bad:
keep an HTTP login endpoint alive purely to mint a cookie — a second auth surface, a second session store, and a CORS/SameSite problem, on a node whose whole point was that the graph is the API;
put the credential in the WebSocket URL’s query string — the classic workaround, and a credential leak: URLs land in server logs, in browser history, in
Referer-adjacent surfaces, and in any proxy in between. libtracer rejects this option; a node should not be documented into leaking its own credentials.
The answer: an in-band authentication frame¶
A third admission point, after the 101 and before the session is served:
The socket is upgraded. It exists. It is served nothing until it presents a credential the node accepts, and it is closed if it does not present one in time.
The credential travels as an ordinary WebSocket data frame, which a browser can send. The handshake-header path is unchanged and still available to peers that can use it; the two are complementary, not alternatives, and a node may require both.
One node, two kinds of peer¶
A node that is reachable by a browser is usually also reachable by a native peer — another node, a CLI, a gateway — and a native peer authenticates at the handshake, because it can. So the interesting configuration is not “frame instead of header”; it is both at once, on the same endpoint, with each peer using the one it is capable of.
That only works if the handshake check can say “this one is already authenticated”. Otherwise installing the frame check makes it mandatory for everybody, and a peer whose whole credential was in the header — and which has no way to send a frame — is closed at the deadline with a perfectly good credential. So the handshake check answers three ways rather than two:
Handshake verdict |
Then |
|---|---|
refuse |
no upgrade; the peer never reaches a session |
admit |
upgraded, and not authenticated: a credential frame is required, and the deadline runs |
admit, authenticated |
upgraded and served from the 101; no credential frame, no deadline, and the session may stay silent indefinitely |
The third verdict is what makes the two admission points composable rather than exclusive. It is a property of one session, not a switch on the link: the same node answers “admit, authenticated” to a peer presenting a header and “admit” to a browser presenting nothing, in the same second.
A node that serves only browsers never needs it, and a node with no frame check is unaffected by it.
What “served nothing” means¶
Between the 101 and acceptance, the session is not a peer. Concretely, an unauthenticated session:
It cannot… |
because |
|---|---|
read or write a vertex |
its frames go to the credential check, never to the graph |
subscribe |
same — a SUBSCRIBE is a graph op, and no graph op is reachable |
receive a subscription push |
a broadcast skips it |
receive a directed reply |
it cannot be resolved to a sending endpoint |
be discovered |
it is absent from the peer census, so it appears in no synthesized |
That list is deliberately the whole surface, inbound and outbound. Gating only the inbound direction would leave a socket that presented nothing still receiving every value the node publishes — which is the more serious of the two leaks.
The payload is opaque¶
The node does not interpret the credential. The frame is a carrier: its payload is handed to the embedder’s check verbatim, and what counts as a valid credential is entirely that check’s business.
This is a load-bearing commitment rather than an omission. A bearer token is simply the first payload kind; the same frame carries a challenge–response exchange, and is intended to carry an ed25519/Noise handshake unchanged when that lands. An authentication frame specified as “a token, and here is its layout” would have had to be replaced; one specified as a carrier does not.
Accordingly the check answers one of three verdicts, not two:
accept — serve this session from the next frame on;
continue — not finished; expect another frame (a multi-round-trip handshake);
reject — close it.
Any verdict may carry a reply payload back to the peer in the same shape — the responder message a handshake needs, or an application-level explanation alongside a rejection.
The subject¶
On acceptance the check may bind a subject to the session: the identity of whoever is on the other end, as text (an identity that is natively bytes — a public key — is spelled in hex or base64).
The session carries the subject and publishes it for operators. The handler-side half
now exists: on_write takes a write_ctx_t whose subject is the writer’s resolved subject
token — the very value the vertex’s ACL gate was evaluated against
(#375 Part 1). Part 2 built the
transport half: every locally-terminating frame carries the opaque peer_handle_t its link
minted at accept, and the terminus derives the caller context from that handle through
transport_t::peer_subject — at either setting of peer_named, so a per-writer subject no
longer depends on the addressing facet. A stream listener answers that door with its p<slot>
session token.
What is still open is the last step of the join: a subject bound by the authentication
check below is not yet what peer_subject returns for that session, so a handler sees the
session’s transport-minted token rather than the identity the peer actually proved. The seam
to close it is now one override away — the link already knows the handle and already holds the
session’s bound subject beside it. The authentication frame is the right place to capture an
identity because it is the first moment one is known; it is not the right place to decide the
whole authorization model.
A subject is not the same claim as a peer name, and the two are deliberately kept
apart (ADR-0082):
the subject answers who wrote this, while a listener’s peer_named flag answers where in
the graph this peer appears. A name is assigned locally by the transport at accept and
attests nothing about the far end; only a subject bound by a check like the one above carries
a claim worth authorizing against. They compose, and neither implies the other — so turn
peer_named on when you must address, ACL or tear down an individual peer by name (and
budget its per-peer session-anchor vertex), and read the subject when you need identity for
an auth decision. peer_named stays off by default for exactly that reason.
The deadline¶
An unauthenticated session is a new kind of resource holder. Before the frame existed, a peer that failed admission never reached a session slot at all — it was refused at the handshake. Now it holds one while it is deciding what to send, which on an embedded node with a small peer cap is a cheap denial of service: open sockets, say nothing, and the cap is spent.
So the window is bounded. A session that has not been accepted within a configured deadline is closed. Two properties matter more than the exact number:
The deadline is not extended by the peer’s own traffic. A multi-frame handshake gets the whole window for all of its frames. A peer that could refresh the deadline by sending anything at all would effectively have none.
An expired session cannot deny a live peer its slot. The reap that frees expired sessions runs when a new peer arrives, not only on the periodic sweep — so admission always answers from sessions that are still live, whatever the sweep’s timing.
The deadline bounds the SOCKET, not only the session. The session is claimed lazily, on the peer’s first data frame, so a peer that completes the 101 and then sends nothing at all never holds a session — and for a while it was therefore bounded by nothing here, which is exactly the “open sockets, say nothing” case above. The implementation records each upgraded socket at the pre-handshake, with its deadline, and the sweep closes an un-spoken one on the raw descriptor with the same
4408. The record set is fixed-size; a handshake that would overflow it is refused — the 101 is never written — because a socket with no record is a socket nothing can close.It bounds only the sockets whose entitlement is not yet known. A handshake that answered admit, authenticated has already given the answer the deadline exists to time out, so its session is claimed at the 101 and takes no record and no deadline at all. Recording it anyway is a defect with two heads: the sweep closes a perfectly good peer that simply had nothing to say yet, and — since a closing socket does not retire a record — exempting such records from the sweep instead would let idle authenticated links fill the fixed set and stall admission for everybody. Claiming the session outright removes both. The cost is that the peer cap is charged at the 101 for this verdict, which is the correct charge: the peer is authenticated and entitled, and a refusal at the upgrade is a cleaner answer than admitting it and closing it a deadline later.
The deadline bounds the exposure from the upgrade onward and no other. A client that connects a TCP socket and never upgrades at all is an ordinary HTTP-server concern, governed by the server’s own socket budget, and is neither worsened nor improved by anything here.
Close codes¶
A rejected peer and an expired peer need different client behaviour — re-prompt for a credential versus retry the connection — so they are different close codes, in RFC 6455’s application range (4000–4999):
Code |
Meaning |
Client should |
|---|---|---|
|
the credential was refused |
not retry with the same credential; obtain a new one (mnemonic: HTTP 401) |
|
the deadline expired before a credential arrived |
retry; the credential itself was never judged (mnemonic: HTTP 408) |
|
the session was REVOKED by the node ( |
stop reconnecting; the credential was fine, the access was withdrawn (mnemonic: HTTP 403) |
The third is not an authentication verdict at all — it is the administrative teardown
bus_link_t::close_peer performs, and it is listed here because it shares the wire surface
and a client that cannot tell it from the other two behaves wrongly in the most expensive
direction: a revoked controller reading its close as a network fault reconnects forever.
The code is written to the peer before the socket is torn down. A close that shut the socket first would deliver no code at all, and a client left to infer the reason from a bare disconnect will infer it wrong — most often by retrying a credential that will never be accepted.
Both codes are chosen once, here, so that every implementation and every client agrees on them.
Observability¶
Refusals at the three admission stages are counted separately, because they are facts about different actors and summing them hides which is happening:
Signal |
Says |
|---|---|
handshake refusals |
a header/predicate check turned a peer away, the peer cap was full, or the pending-handshake record set was full — the peer never reached a session |
credential rejections |
a credential was offered and refused — the shape of a brute-force attempt, or of a fleet holding a stale token |
deadline expiries |
peers that connected and said nothing — both the session that stalled mid-handshake and the socket that upgraded and never spoke. A trickle is normal (a tab closed mid-login); a rate that tracks connection attempts is not |
Unauthenticated sessions are deliberately not enumerated per-session. A per-session census of things that have not identified themselves is a census of an attacker’s socket count; the link-level tallies above are the right altitude.
Reference implementation¶
The ESP-IDF WebSocket server link (httpd_ws_link_t,
integrations/esp-idf/libtracer/)
implements this: set_auth_cb installs the check, the constructor takes the deadline, and
the close codes are kCloseAuthFailed / kCloseAuthTimeout. The three-valued handshake
verdict is set_admission_verdict_cb with admission_verdict_t::ADMIT_AUTHENTICATED. Its
host suite (httpd_ws_auth_test) pins each property above against the real translation unit.
See also 12-deployment-profiles.md for where a browser-facing node sits, and 13-network-formation.md for how peers find each other once they are allowed to talk.