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— everythingauthenticateddoes, 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).
How the level gets chosen
Section titled “How the level gets chosen”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.
One channel works at every level
Section titled “One channel works at every level”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 }):
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:
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] });