Skip to content

WebSocket authentication: single-use tickets ​

/api/ws carries remote-control commands from the dashboard to agents (docker stop/pause/unpause, kill <pid>), so every connection must be authenticated and tied to a role. This document explains how Servomon does that with single-use handshake tickets, why it uses tickets, and what the design costs.

Two kinds of secret appear below:

  • A credential is long-lived. The operator's HTTP Basic password comes from NUXT_BASIC_AUTH_USERS. An agent's AGENT_TOKEN is issued during agent enrollment. That document explains approval, token collection, and the current device-verification limitations; see "Adding an agent" in the README for setup.
  • A ticket is short-lived: 256 random bits, valid for 60 seconds, usable once, tied to a role. Only tickets ever appear in a WebSocket URL. Credentials never do.

Summary ​

  1. The client sends its credential to POST /api/ws-ticket over normal HTTPS. The operator uses HTTP Basic; an agent uses Authorization: Bearer <AGENT_TOKEN>.
  2. The server returns a ticket bound to that principal: operator plus username, or agent plus agent id and system name.
  3. The client opens wss://<host>/api/ws?ticket=<ticket>.
  4. The crossws upgrade hook consumes the ticket before the handshake completes. Any failure is answered with HTTP 401 or 403, and no socket is created.
  5. The connection's role is fixed until it closes. Every reconnect mints a new ticket.

Why the WebSocket needs its own mechanism ​

  • Browsers can't set headers on a WebSocket. The browser API is new WebSocket(url, protocols), with no way to send Authorization (MDN). The other /api routes authenticate with headers, so the WebSocket can't simply reuse them.
  • The protocol leaves authentication to the application. RFC 6455 says servers "can use any client authentication mechanism available to a generic HTTP server" (§10.5).
  • Relying on credentials the browser attaches automatically invites hijacking. If the handshake accepted whatever credentials the browser happened to attach, any website the operator visits could open a socket to Servomon on their behalf. That attack is cross-site WebSocket hijacking (CSWSH, PortSwigger).
  • Long-lived credentials don't belong in URLs. URLs end up in access logs, so the password or AGENT_TOKEN must never be part of one (websocket.org).

Tickets meet all four constraints. The credential travels once, in a header, on an ordinary HTTPS request. Only a disposable value goes into the WebSocket URL, and the server checks that value before it accepts the connection.

How it works ​

Dashboard (operator) ​

The system detail page (/systems/:name) opens the command socket. The browser resends the Basic credentials it cached when the dashboard loaded, so the ticket request needs no extra login.

Agent ​

Enrollment happens once and gives the agent its AGENT_TOKEN. From then on, every connection attempt, including each automatic reconnect, mints a new ticket.

The server takes the agent's system name from the token, never from the request. The name query parameter only has to match the name stored on the ticket, so an agent can't connect as another system.

What the upgrade hook checks ​

The hook deletes the ticket before any role or system check runs. A ticket presented with the wrong role or system is therefore used up, and a retry with it gets HTTP 401. Every accept and reject is audit-logged with the client IP, Origin, and reason; ticket values are never logged. The whole hook runs inside try/catch so an internal failure answers HTTP 500 instead of leaking the raw socket.

Upgrades to any path other than /api/ws never reach this hook: a Nitro plugin (web/server/plugins/10-ws-limits.ts) injects verifyClient into the WebSocket server, which rejects them with HTTP 401. Without that gate, crossws would accept such upgrades with an empty hook set — no ticket check, sockets held indefinitely.

Ticket lifecycle ​

The checked ticket travels from upgrade to open via crossws context: the hook returns { context: { ticket } } and open reads it as peer.context (crossws hooks). crossws is pinned to 0.4.16 via a pnpm override, which also vendors a patched ws receiver (fragment/chunk memory-exhaustion and close() memory-disclosure fixes).

Ticket properties ​

PropertyValueCode
Format32 random bytes, base64url (43 characters, 256 bits)mintWsTicket, web/server/utils/ticketStore.ts
Lifetime60 s from mint to use; override with NUXT_WS_TICKET_TTL_SECONDSttlSeconds
UsesOne. The first time the ticket is presented, it's deleted, even if the connection is then rejectedconsumeWsTicket
Bound toRole, plus the operator username or the agent id and systemWsTicket type
StorageIn-process Map, never written to diskticketStore.ts
CleanupUsed tickets are deleted immediately; expired ones are removed on every mintsweepWsTickets
Who can mintOperator (Basic) or an enrolled agent (Bearer)web/server/api/ws-ticket.post.ts, web/server/middleware/10-api-auth.ts
Guessing2^256 possibilities, and lookup is an exact Map match, so there's no partial-match timing to exploitMap.get

After the handshake ​

Each connection's role determines what it may send. Validation happens on every message, not just at connect time. Before any parsing, each message passes three gates: a 64 KiB byte cap (larger messages close the socket), a per-connection rate limit (100 messages/minute with a burst of 20), and liveness tracking.

RoleMay sendServer action
operatorkill with a pid of 1 to 7 digits, or docker stop/pause/unpause for a container the target agent has reported in its stored metricsForwards the command only to the live agent socket for system, stamped with a server-generated commandId. The decision is audit-logged whether it's forwarded or dropped
agentCommand results matching commandResultSchema with a commandId matching an outstanding forwarded commandSends the result to every operator socket with the agent's system attached. Anything else — malformed, unsolicited, duplicate, or for another system — is dropped, so agents can't command each other or forge results

Outstanding command ids expire after 5 minutes. Operator credentials are revalidated every 30 minutes: an operator removed from NUXT_BASIC_AUTH_USERS has their socket closed. (Password rotation takes effect on redeploy, which drops all sockets.)

Dead and idle sockets don't linger: the server pings every 30 seconds, terminates peers that miss a pong, and closes sockets idle for 10 minutes. Fan-out checks each operator's buffered bytes and drops slow consumers past 256 KiB rather than queueing unbounded memory. At most 20 operator sockets may be open at once.

Close or statusMeaning
HTTP 401 at handshakeTicket missing, unknown, already used or expired; or an upgrade to any path other than /api/ws
HTTP 403 at handshakeOrigin mismatch, role or system mismatch, or the agent is no longer enrolled
4401The socket opened without a session (defensive; shouldn't happen in normal use)
4403The agent was removed from the dashboard while connected
4408Session expired: idle timeout, heartbeat timeout, rate limit, slow consumer, or revoked operator
4409A newer connection for the same system replaced this one, or a message exceeded 64 KiB

A failed handshake gives the client no HTTP status to read. The WebSocket API reports only an error and close code 1006. Authentication failures therefore show up at the ticket-mint step, which is a normal HTTP request with a readable status. That's where the agent logs WS ticket mint failed with HTTP 401.

Why tickets stop cross-site WebSocket hijacking ​

PortSwigger describes a vulnerable handshake as one that relies on cookies (or other credentials the browser sends automatically) and carries no "CSRF tokens or other unpredictable values". The ticket is that unpredictable value.

The attacker's page can make the operator's browser request a ticket, but it can't read the response. Without a ticket, the handshake is refused. This protection holds only while /api/ws-ticket returns no CORS headers that allow credentials (see Operating considerations). Tickets don't help against XSS on Servomon's own origin: a script running there can mint tickets just as the dashboard does.

Trade-offs ​

Benefits ​

  • Long-lived credentials never appear in a URL.
  • Bad connections are refused before the handshake completes, so the server never holds unauthenticated sockets or needs an authentication timeout. Upgrades to paths other than /api/ws are rejected at the transport layer.
  • CSWSH is blocked twice over: the ticket is an unpredictable handshake value, and a present Origin must match the host.
  • The browser and the agent use one code path: resolveUrl in shared/lib/WebsocketClient.ts.
  • Revocation is checked on every connect and re-checked during the session. An agent removed from the dashboard can't mint tickets, and its open sockets are closed with 4403; a removed operator's sockets close on the next revalidation window.
  • The server sets each connection's identity. A name in the URL is checked against the ticket and never trusted on its own.
  • Commands and results are correlated: every forwarded command carries a server-generated commandId, and results without a matching outstanding id are dropped — no forged or replayed results reach the dashboard.
  • Resource use is bounded: 64 KiB messages, per-connection rate limits, heartbeats with idle cleanup, backpressure-checked fan-out, and a cap on operator sockets.

Costs ​

  • An extra HTTP round trip on every connect and reconnect.
  • Server-side state. Tickets, sessions, and outstanding commands live in one process's memory, so Servomon must run as a single instance (web/fly.toml pins one machine). A restart drops outstanding tickets; clients just mint new ones.
  • Tickets still reach any log that records full URLs. Usually the logged request is the one that used the ticket, so a ticket read from a log has already been spent. The exception is a proxy that logs the URL but never delivers the request to the app (upstream down, timeout). That ticket stays valid until its TTL runs out, so someone reading logs in real time could replay it within 60 s.
  • A Nitro plugin reaches into internals. 10-ws-limits.ts mutates the cached h3App.websocket object to pass serverOptions to the adapter. Verified against the installed Nitro/crossws sources, but it depends on undocumented wiring that a major upgrade could move.
  • Stale agents lose results. Agents older than the commandId change send results without correlation ids, which the server drops. Agents must be redeployed with the server.

Where the guides disagree ​

The ticket pattern is long established, but the guidance on query parameters is split:

Servomon accepts the URL risk because the ticket is single-use and expires in 60 s. In return, the server refuses bad connections before the handshake finishes. If your policy bans credentials in URLs entirely, send the same ticket as the first message instead. The costs are listed in the table below.

Alternatives considered ​

OptionHow it worksWhy not chosen
Long-lived token in the URL (?token=AGENT_TOKEN)The client puts its real credential in the WebSocket URLThe credential would sit in every log that records URLs and stay valid indefinitely. websocket.org warns against exactly this
Credentials the browser attaches automatically (cached Basic auth, cookies)Trust whatever the browser sends with the handshakeThis is the setup CSWSH exploits, and it's only safe with an Origin allowlist. Basic auth has no session to expire or log out, and agents don't use it
Session cookie plus Origin allowlistAdd a login that sets a SameSite session cookie, and check the cookie and Origin at upgradeOWASP prefers this for browsers, but Servomon has no session layer. Building one (login, logout, expiry, CSRF protection) is far more code than tickets, and agents would still need a separate path
First-message authenticationOpen the socket without authentication, send the token as the first frame, close on timeoutKeeps credentials out of URLs, but the server must accept unauthenticated sockets, add an authentication timeout and a pre-auth state in every hook, and rate-limit unauthenticated connections per IP
Token in Sec-WebSocket-ProtocolEncode the bearer token as a fake subprotocol, as Kubernetes does for browser clientsMisuses a negotiation header, requires the server to echo back a real subprotocol, and still sends a long-lived credential in a header that proxies may log
Signed stateless tickets (HMAC or JWT with exp)The server signs {role, system, exp} and upgrade verifies the signatureNeeds no shared store across instances, but single-use still requires a replay cache (state again), plus key management and rotation. This is the natural upgrade path if Servomon ever runs more than one machine
Authorization header on the agent's handshakeDeno's WebSocket accepts a non-standard headers option (verified with Deno 2.9.8)Works for agents only. It would add a second authentication path to the upgrade hook and split the shared client code. One ticket path for both clients is simpler to reason about and test
Mutual TLS for agentsAgents present client certificatesThe strongest machine identity, but with the current http_service setup, TLS terminates at Fly's proxy. Issuing and rotating certificates is heavy for homelab users, and it doesn't help the browser
Network-level gate (Tailscale, Cloudflare Access, VPN)Don't expose the app publiclyA good extra layer and the right containment while a fix ships, but not a replacement: the app itself must be safe when exposed

Operating considerations ​

  • Serve only over TLS (https/wss). Over plain ws://, anyone on the network path can read a ticket and race the real client to use it before the TTL expires. The ticket request would also expose the long-lived Basic or Bearer credential. Fly.io handles TLS for *.fly.dev; self-hosted setups need a TLS-terminating proxy.
  • Run a single instance, or share the store. A ticket minted on machine A doesn't exist on machine B. To scale out, move tickets to a shared store with an atomic get-and-delete (for example Redis GETDEL), or switch to signed tickets with a shared replay cache.
  • Don't add credentialed CORS to /api/ws-ticket. For example, never reflect Origin together with Access-Control-Allow-Credentials: true. CSWSH protection depends on other origins being unable to read the ticket response.
  • TTL tuning. Clients mint and connect immediately, so real use takes milliseconds; 60 s is generous. A shorter TTL narrows the log-replay window. The cost is failed attempts on very slow links, which recover on their own because every reconnect mints a new ticket.
  • No clock skew. Tickets are minted and checked in the same process with Date.now(), unlike a JWT exp checked on another machine.
  • Logs. Proxies and platforms that log full request URLs will record tickets, which are normally already used by then (see Costs). Keep such logs out of third-party pipelines, or strip query strings when logging.
  • Revoked agents. After an agent is removed, its ticket mint returns HTTP 401. The client keeps retrying with exponential backoff (1 s doubling to a 60 s cap) and stops after 20 attempts. To re-enroll, clear AGENT_TOKEN in the agent's .env and restart it.

Known gaps and possible hardening ​

  1. No rate limiting on handshake attempts or ticket minting. Minting requires a valid credential, and guessing a ticket is infeasible, so the risk is resource use rather than access. Per-connection message limits exist; handshake floods rely on Fly edge protections.
  2. Tickets aren't tied to the client's IP address. Ronacher and Heroku both mention storing the IP on the ticket. With a single-use, 60 s ticket the gain is small, and proxies and mobile networks make IP checks brittle.
  3. kill checks the pid format but not existence. The pid isn't checked against the processes the agent reported. Docker commands, by contrast, must name a container the agent has reported.
  4. No per-agent connection cap. One agent socket exists per system by replacement, but nothing stops many systems' agents from connecting at once. The operator side is capped at 20.

Fixed by the hardening pass: Origin allowlist on the handshake, handshake/violation/error audit logging with identity/IP/Origin, 64 KiB message caps (transport + app), per-connection rate limits, heartbeats with idle cleanup, backpressure-checked fan-out, command/result correlation with replay rejection, operator credential revalidation, non-/api/ws upgrade rejection, and the patched ws receiver via the crossws 0.4.16 override.

Code map ​

FileRole in the ticket flow
web/server/middleware/10-api-auth.tsLets Upgrade: websocket requests through to the upgrade hook, denies plain HTTP on /api/ws, requires Basic or Bearer on /api/ws-ticket
web/server/plugins/10-ws-limits.tsInjects maxPayload: 64 KiB and path-restricting verifyClient into the WebSocket server before the adapter is built
web/server/api/ws-ticket.post.tsWorks out which principal is calling and mints the matching ticket
web/server/utils/ticketStore.tsMint, consume (single-use), TTL, sweep
web/server/api/ws.tsupgrade checks (Origin, ticket, role); open binds the session; message applies size/rate/correlation/role rules and writes the audit log; pong/error/close handle liveness and teardown
shared/lib/WebsocketClient.ts, web/shared/lib/WebsocketClient.tsresolveUrl mints a new ticket on every connection attempt
web/pages/systems/[name].vueThe dashboard's ticket request
agent/main.tsThe agent's ticket request
agent/lib/CommandResponderWebsocketClient.tsEchoes the server's commandId in results so they correlate
shared/types.ts (+ generated zod schemas)Optional commandId on commands and results

Tests ​

pnpm test in web/ starts the production build with isolated storage and checks the ticket behaviour from outside, over real HTTP and WebSocket connections:

  • Anonymous access: anon cannot mint a WS ticket, anon WS handshake is rejected at HTTP layer, anon dashboard socket is rejected, anon agent socket is rejected, anon client cannot forward commands to an agent
  • Minting: operator can mint a WS ticket, agent can mint a WS ticket bound to its system, WS ticket with wrong operator password is rejected, WS ticket with wrong agent token is rejected
  • Ticket rules: WS ticket is single-use, expired WS ticket is rejected (the suite uses a 2 s TTL), agent ticket cannot connect as another system, operator ticket cannot connect as an agent
  • After the handshake: operator command reaches its agent and the result comes back, agent cannot send commands to another agent, removed agent loses token and socket immediately
  • Hardening: upgrade on non-command paths is rejected, handshake with foreign Origin is rejected, handshake with matching Origin is accepted, handshake without Origin is accepted (non-browser clients), oversize message is rejected and socket closed, message flood is rate limited, unsolicited agent result is dropped, duplicate result delivery is dropped, handshake decisions are audit-logged without secrets

Further reading ​