A webterminal is one of those features that looks like an afternoon of work and turns into a security review. The terminal part genuinely is easy: the Heyo SDK gives you a PTY-attached shell over a WebSocket, and xterm.js renders it. The part worth thinking about is the API key.
This guide builds the whole thing with Bun and the TypeScript SDK: a user pastes a heyvm API key, picks one of their networks and a VM, registers the VM into the network, and gets an interactive shell in the browser. Roughly 500 lines.
The finished example lives at Heyo-Computer/demo-webterminal — clone it if you'd rather read the whole thing than follow along.
The catch
The obvious design is to let the browser talk straight to the Heyo cloud. It doesn't work, for a reason worth understanding.
Browsers cannot set headers on a WebSocket. There is no third argument to the
constructor, no way to send Authorization: Bearer. The SDK works around this
the only way anyone can — by putting the token in the query string:
// node_modules/@heyocomputer/sdk/src/shell.ts
const url = new URL(client.wsUrl(path))
const auth = client.wsHeaders().Authorization
if (auth?.startsWith('Bearer ')) {
url.searchParams.set('token', auth.slice('Bearer '.length))
}
That is fine when the caller is your own server. It is not fine in a browser: a URL is not a secret. It lands in devtools, in the browser's own network log, and in the access log of every proxy between there and the cloud.
So the browser never talks to the cloud. Your server does. It holds the key in memory and proxies the byte stream. That single decision shapes everything else.
Shape
browser ──HttpOnly cookie──▶ your Bun server ──Bearer key──▶ Heyo cloud
xterm.js session vault PTY
◀──── raw PTY bytes ──── (memory only) ◀─── WS ───
The browser gets an opaque session id in an HttpOnly cookie. The key itself
never goes back over the wire after login.
To follow along from scratch:
bun init
bun add @heyocomputer/sdk @xterm/xterm @xterm/addon-fit react react-dom
Or start from the finished example and read the code as you go:
git clone https://github.com/Heyo-Computer/demo-webterminal
cd demo-webterminal
bun install
bun run dev # http://localhost:3000
Step 1: the vault
This is the only file that ever sees a key, so it's the one to get right. The trick worth stealing: keep keys in a separate map from session metadata.
// src/sessions.ts
export interface Session {
readonly id: string
readonly createdAt: number
lastSeenAt: number
readonly shells: Set<ClosableShell>
}
const keys = new Map<string, string>() // id → API key. Module-private.
const sessions = new Map<string, Session>() // id → metadata. No credential.
The Session object that gets passed around the rest of the app has no apiKey
field at all. A stray console.log(session) or JSON.stringify(session)
structurally cannot leak a key — you don't have to remember to redact it,
because there's nothing there to redact. Only getApiKey(id) can reach the
other map, and it's called in exactly two places.
Session ids are 256 bits of real randomness, not randomUUID (122 bits, with
version and variant structure you don't want in something bearer-equivalent):
function newSessionId (): string {
const bytes = new Uint8Array(32)
crypto.getRandomValues(bytes)
return Buffer.from(bytes).toString('base64url')
}
Two clocks: idle (refreshed on each request) and absolute. touch() folds
"unknown id" and "expired id" into the same answer so callers just return 401:
export function touch (id: string | undefined | null): Session | undefined {
if (!id) return undefined
const session = sessions.get(id)
if (!session) return undefined
const now = clock()
if (isExpired(session, now)) {
void destroy(id)
return undefined
}
session.lastSeenAt = now
return session
}
destroy() kills the session's live shells before forgetting it, and a
setInterval sweeper calls it for anything that aged out. Nothing is written to
disk — restarting the process invalidates every session, which is a feature.
Step 2: the cookie
// src/http.ts
export function sessionCookie (
req: Request,
id: string,
maxAge: number,
): string {
const parts = [
`hwt_sid=${encodeURIComponent(id)}`,
'HttpOnly', // document.cookie can't read it — XSS can't steal it
'SameSite=Strict', // no cross-site request rides it
'Path=/',
`Max-Age=${maxAge}`,
]
if (isSecureRequest(req)) parts.push('Secure')
return parts.join('; ')
}
SameSite=Strict handles most CSRF, but a WebSocket upgrade is a GET and
browsers have historically treated those less strictly, so check Origin
explicitly too — including on the upgrade:
export function isSameOrigin (req: Request): boolean {
const origin = req.headers.get('origin')
if (!origin) return false // browsers always send one here
const host = req.headers.get('host')
if (!host) return false
return origin === `${isSecureRequest(req) ? 'https' : 'http'}://${host}`
}
Login validates the key against the cloud before minting anything, so a bad key never produces a cookie:
"/api/session": {
async POST(req) {
if (!isSameOrigin(req)) return httpError(403, "Cross-origin request rejected");
const { apiKey } = await req.json();
if (typeof apiKey !== "string" || apiKey.length < 8) {
return httpError(400, "Missing or malformed apiKey");
}
if (!(await heyo.validateKey(apiKey))) {
return httpError(401, "The Heyo cloud rejected that key");
}
const sessionId = sessions.create(apiKey);
return json({ ok: true }, { // note: no key material in the body
headers: { "set-cookie": sessionCookie(req, sessionId, 1800) },
});
},
}
validateKey is just the cheapest authenticated call there is:
export async function validateKey (apiKey: string): Promise<boolean> {
try {
await Sandbox.list({ apiKey })
return true
} catch (err) {
if (err instanceof AuthenticationError) return false
if (err instanceof ApiError && (err.status === 401 || err.status === 403))
return false
throw err // a real outage isn't a bad key — don't swallow it
}
}
From here every route resolves the caller from the cookie and nothing else. No route accepts a key in a header, body, or query string. That's what makes one browser session structurally incapable of seeing another's VMs.
Step 3: listing VMs
Your VMs live in two places: cloud-deployed sandboxes and sandboxes on daemons you run yourself. Both are worth showing.
const [cloudResult, daemonsResult] = await Promise.allSettled([
Sandbox.list({ apiKey }),
Daemons.list({ apiKey }),
])
allSettled, not all — a laptop that went to sleep shouldn't blank the page.
Same for the per-daemon fan-out, where a 502 becomes a warning row:
const daemonListings = await Promise.allSettled(
daemons.map(d => Daemons.listSandboxes(d.id, { apiKey })),
)
daemonListings.forEach((result, i) => {
const daemon = daemons[i]!
if (result.status === 'rejected') {
warnings.push({
source: daemon.id,
sourceLabel: daemon.name ?? daemon.id,
message: `${describe(result.reason)} (daemon is ${daemon.status})`,
})
return
}
for (const sb of result.value.sandboxes) {
vms.push({ /* … */ kind: sb.isDeployed ? 'deployed' : 'local' })
}
})
That kind matters for the next step. Cloud sandboxes register as deployed;
daemon-native ones as local.
Step 4: into a network
Networks are per-account. The default one is created lazily — but only by
Network.default(), which maps to GET /networks/me. Network.list() maps to
GET /networks, which returns the rows that already exist and creates nothing.
On an account that has never touched networks that's an empty array, and an
empty picker with no way to register anything. Touch the default first:
await Network.default({ apiKey }) // creates it if it isn't there
const infos = await Network.list({ apiKey }) // now guaranteed non-empty
const net = await Network.get(networkId, { apiKey })
await net.addMember({
sandboxKind: vm.kind,
sandboxRef: vm.id,
deviceName: vm.name,
})
addMember is idempotent on (networkId, kind, ref), so a double-click just
updates the device name.
One thing to be deliberate about: derive sandboxKind server-side. It's
tempting to send it from the client alongside vmId, since the client already
has it. Don't — look it up from your own listing instead:
const { vms } = await listVms(apiKey)
const vm = vms.find(v => v.id === vmId)
if (!vm) throw new NotFoundError(`No VM ${vmId} is visible to this session`)
That one lookup means a client can't register an arbitrary ref under an arbitrary kind, and it doubles as the authorization check — a VM the session can't see is a 404.
Step 5: the proxy
Now the actual terminal. Validate the session before upgrading:
"/ws/shell": (req, srv) => {
if (!isSameOrigin(req)) return httpError(403, "Cross-origin request rejected");
const auth = requireSession(req);
if (auth instanceof Response) return auth;
const url = new URL(req.url);
const vmId = url.searchParams.get("vm");
if (!vmId) return httpError(400, "Missing ?vm");
// Note what's NOT in here: the API key.
const data = {
sessionId: auth.sessionId, vmId, cols, rows,
shell: null, slot: null, closed: false,
};
if (srv.upgrade(req, { data })) return undefined;
return httpError(400, "WebSocket upgrade failed");
},
The socket carries the session id, not the key. The key gets read from the
vault once, in open, and stays there.
websocket: {
data: {} as ShellSocketData,
idleTimeout: 600, // a PTY idles between keystrokes
backpressureLimit: 8 * 1024 * 1024, // don't let a slow client balloon us
closeOnBackpressureLimit: true,
async open(ws) {
const apiKey = sessions.getApiKey(ws.data.sessionId);
if (!apiKey) return ws.close(4401, "Session expired");
const shell = await Sandbox.connect(ws.data.vmId, { apiKey })
.shell({ cols: ws.data.cols, rows: ws.data.rows });
ws.data.shell = shell;
shell.onData((chunk) => { if (!ws.data.closed) ws.send(chunk); });
shell.on((evt) => {
ws.send(JSON.stringify(serializeEvent(evt)));
if (evt.type === "closed") ws.close(1000, "Shell closed");
});
ws.send(JSON.stringify({ type: "ready", vmId: ws.data.vmId }));
},
async message(ws, message) {
const shell = ws.data.shell;
if (!shell) return;
if (typeof message === "string") {
const msg = JSON.parse(message);
if (msg.type === "resize") await shell.resize(msg.cols, msg.rows);
return;
}
await shell.write(message);
},
async close(ws) {
ws.data.closed = true;
await ws.data.shell?.kill();
},
}
Keystrokes are binary frames, control messages are text. Pick a convention
like this and the two can never be confused — no length prefixes, no escaping,
no if (looksLikeJson(msg)).
sandbox.shell() is not one endpoint
That Sandbox.connect(vmId).shell() above is right for cloud VMs and wrong for
daemon-hosted ones, and the failure is not obvious. shell() always targets
/deployed-sandboxes/{id}/shell-stream, which resolves ids against the
deployed sandbox table. Hand it a daemon-native sb-… id and the lookup
misses, the upgrade is rejected, and the socket closes before the ready frame:
shell-stream socket closed before ready (code=1006) — the server may not
support /deployed-sandboxes/{id}/shell-stream (check the cloud version)
Your cloud is fine. You asked the wrong route. Daemon sandboxes stream from
their daemon's own endpoint, which the cloud proxies into heyvmd over iroh —
reachable via the pathOverride that Daemons.hostShell uses internally:
import { HeyoClient, ShellSession } from '@heyocomputer/sdk'
if (vm.source === 'cloud') {
return await Sandbox.connect(vm.id, { apiKey }).shell(options)
}
const session = new ShellSession(new HeyoClient({ apiKey }), vm.id, {
...options,
pathOverride: `/me/daemons/${vm.source}/sandboxes/${vm.id}/shell-stream`,
})
await session.open()
return session
Note where vm.source comes from: your own listing, looked up server-side by
id — the same re-derivation as sandboxKind in step 4, and the same payoff. A
VM the session can't see is a 404 before any socket is opened.
The daemon route additionally requires the sandbox to be exposed for remote
access. That's the same list Daemons.listSandboxes() returns, so if a VM made
it into your picker it is already shellable — the two checks agree by
construction.
Three details that only show up under load:
Serialize your events by hand. ShellEvent carries Error objects, and
JSON.stringify(new Error("boom")) is {}. Pull the message out explicitly or
your UI shows an empty box where the failure reason should be.
Reserve the slot before you await. Capping shells per session is easy to get
subtly wrong — check the cap after the await and N parallel connects all sail
past it. Register a placeholder first:
const slot = {
kill: async () => {
await ws.data.shell?.kill()
},
}
if (!sessions.registerShell(ws.data.sessionId, slot)) {
return ws.close(4429, 'Shell limit reached')
}
ws.data.slot = slot
// …now it's safe to await the upstream connect
Handle the client leaving mid-connect. close can fire while open is still
awaiting upstream, so check the flag afterwards or you leak a PTY:
if (ws.data.closed) {
sessions.unregisterShell(ws.data.sessionId, slot)
await shell.kill()
return
}
Reconnects you get for free — the SDK retries with backoff and the cloud holds
the PTY for ~60s, replaying output past the last acked sequence number. Forward
the reconnecting / reconnected events so the UI can say so.
Step 6: the browser
const term = new Terminal({ cursorBlink: true, fontSize: 13 })
const fit = new FitAddon()
term.loadAddon(fit)
term.open(host)
fit.fit()
const ws = new WebSocket(
`${proto}//${location.host}/ws/shell?vm=${vm.id}&cols=${term.cols}&rows=${term.rows}`,
)
ws.binaryType = 'arraybuffer'
const encoder = new TextEncoder()
term.onData(chunk => ws.send(encoder.encode(chunk))) // binary out
ws.onmessage = event => {
if (event.data instanceof ArrayBuffer) {
term.write(new Uint8Array(event.data)) // PTY bytes in
return
}
handleControlFrame(JSON.parse(String(event.data))) // text = control
}
new ResizeObserver(() => {
fit.fit()
ws.send(JSON.stringify({ type: 'resize', cols: term.cols, rows: term.rows }))
}).observe(host)
Set binaryType = "arraybuffer" or you get Blobs and have to await every
chunk. Buffer keystrokes typed while the socket is still CONNECTING — people
start typing before the prompt lands.
On the login screen, the only rule is that the key goes nowhere but the POST:
await api('/api/session', postJson({ apiKey: key }))
setKey('') // drop it the moment it's been exchanged
No localStorage, no sessionStorage, no query param, no document.cookie.
After login the page holds no credential at all — just a cookie it can't read.
Check your work
The negative paths are the ones worth testing, and they're all curl:
curl -s -o /dev/null -w '%{http_code}\n' localhost:3000/api/vms
# 401 — no cookie
curl -s -o /dev/null -w '%{http_code}\n' \
-H 'Cookie: hwt_sid=totally-made-up' localhost:3000/api/vms
# 401 — forged id
curl -s -X POST -H 'Origin: http://evil.test' \
-H 'content-type: application/json' -d '{"vmId":"dep-x"}' \
localhost:3000/api/networks/nw-1/members
# 403 — cross-origin rejected
curl -s -o /dev/null -w '%{http_code}\n' \
-H 'Origin: http://localhost:3000' -H 'Upgrade: websocket' \
-H 'Connection: Upgrade' -H 'Sec-WebSocket-Version: 13' \
-H 'Sec-WebSocket-Key: dGhlIHNhbXBsZSBub25jZQ==' \
'localhost:3000/ws/shell?vm=dep-abc'
# 401 — upgrade needs a session too
Then in the browser: document.cookie should not show hwt_sid, and the
WebSocket URL in the network tab should contain a VM id and nothing else. Grep
your own bundle for apiKey — there should be exactly one hit, the login POST.
The session vault is pure logic, so test it with an injectable clock rather than sleeping through a 30-minute TTL:
let now = 1_000_000
sessions.__setClock(() => now)
const id = sessions.create('key')
sessions.registerShell(id, fakeShell)
now += sessions.IDLE_TTL_MS
expect(sessions.sweep()).toBe(1)
expect(fakeShell.killed).toBe(true)
expect(sessions.getApiKey(id)).toBeUndefined()
And assert the property the whole design rests on, explicitly:
const session = sessions.touch(id)!
expect(JSON.stringify(session)).not.toContain('super-secret-key')
Where to take it
The natural next step is dropping the cloud out of the data path entirely.
Daemons.connectP2p(daemonId) dials your machine directly over iroh and hands
back a client bound to a local tunnel — same Sandbox and ShellSession API,
peer-to-peer transport. It needs the optional @number0/iroh native package.
Past that: multiple tabs against one session (the shell cap is already there for
it), Daemons.hostShell() for a terminal on the host machine rather than a
sandbox, and Network.registerService() to expose a port from the VM you're
sitting in.
But the core is the boring part, and it's worth repeating: the browser never holds the key. Everything else is xterm.js.
The full source — server, frontend, and the test suite for the session vault — is at Heyo-Computer/demo-webterminal.