Skip to content

Security Levels

From self-asserted identity to fully encrypted frames — one level, picked where the connection is opened, used by both ends. The same channel works at every level.

Security is a connection property, chosen once where you open the link — createWireClient or connectChannel — and used by both ends. You don’t touch your channel, your realm, your handlers, or your calls: one ESecurityLevel, and everything riding the socket inherits it.

There are three levels:

  • none — each side just claims who it is; no verification. Fastest; fine for local dev or a trusted network.
  • authenticated (the default) — both sides prove who they are during a handshake (sign/verify, plus pinning each other’s key the first time they meet). Messages travel in the clear.
  • encrypted — everything authenticated does, plus every message is sealed (AES-GCM) with a key worked out during the handshake.
createWireClient({ identity, peer, storage, securityLevel: ESecurityLevel.encrypted, transports });
// …the identical option on connectChannel / serveChannel / serveWireDurableObject.

Why authenticated is safe even in the clear

Section titled “Why authenticated is safe even in the clear”

A natural worry: if authenticated messages travel in the clear, what stops someone spoofing them? Identity is proven once, up front, in the handshake — not per message. The client signs a fresh, single-use challenge with its private key; the server verifies against the key it pinned for that client on first contact (trust-on-first-use). Every later message rides inside that already-proven session, so it’s cheap (no per-message crypto) yet unspoofable by an outsider who never held the key.

The full handshake mechanics, the crypto identity your storage holds, and the TOFU pinning rules (including the durable-storage requirement and how to recover an identity_pin_mismatch) live on Identity & Trust — the one page that owns them. What authenticated deliberately does not give you is confidentiality: payloads are readable on the wire (you lean on TLS for transport encryption, or step up to encrypted to seal each frame).

The dialing side picks the level it wants. By default the listening side accepts any of the three, per connection — so one endpoint can serve all three. If the server persists what it learns about a connection (a carrier that supports sleep/wake), an authenticated or encrypted connection can pick up again after the server hibernates without redoing the handshake.

A realm reads the negotiated level off the connection and refuses to attach below its own required floor — see Realm Security.

The same secure { carrier: wsCarrier(...) } transport works at any level. And httpCarrier runs that same secure session over plain HTTP (handshake → token → encrypted messages) — matching each reply to its request comes for free, since that’s how an HTTP request/response already works.

To offer a secure WebSocket with a plain HTTP fallback, give the listening side an httpAcceptorCarrier({ secure: false }):

client.ts
connectChannel(clientRuntime, appChannel, {
peer: serverCoord,
storage,
securityLevel: ESecurityLevel.encrypted,
transports: [
{ carrier: wsCarrier(() => ({ url: wsUrl })) }, // secure (default)
{ carrier: httpCarrier(() => ({ url: httpUrl })), secure: false }, // plain fallback
],
});

Secure HTTP needs no shared memory between requests

Section titled “Secure HTTP needs no shared memory between requests”

Secure HTTP is stateless: the handshake and session details are carried inside sealed tokens (sealed with the server’s own crypto identity), so any server instance can handle any request. You don’t need a Durable Object just to keep the two POSTs of a handshake on the same instance:

server.ts
const server = serveChannel(runtime, appChannel, {
storage, // only stores the server's crypto identity — never the sessions
carriers: [httpAcceptorCarrier()],
handlers: [appHandler],
});
// app.post("/action", (req) => server.fetch(req))

If your storage is eventually consistent (like Cloudflare KV, where a fresh write isn’t always readable immediately), create the identity once up front so a momentary read-miss can’t accidentally mint a second identity:

import { ClientCryptoKeyLink } from "@nice-code/util";
const link = new ClientCryptoKeyLink({ storageAdapter: storage, identityMode: "required" });
await link.provisionIdentity(); // once, out-of-band (a deploy step / first boot)
serveChannel(runtime, appChannel, { storage, link, carriers: [httpAcceptorCarrier()], handlers: [appHandler] });