Skip to content

Security Levels

Three security levels, from self-asserted identity to fully encrypted frames. You pick one where the connection is opened and both ends use it. The same channel works at every level.

Security is a property of the connection. You choose it once where you open the link, in createWireClient or connectChannel, and both ends use it. Your channel, realm, handlers and calls don’t change: you set one ESecurityLevel, and everything on the socket inherits it.

There are three levels:

  • none — each side claims who it is, with no verification. It is the fastest, and 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, carriers });
// …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 it against the key it pinned for that client on first contact (trust-on-first-use). Every later message travels 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. 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.

On every secure handshake, the dialing side compares the identity advertised by the endpoint with the peer it selected. The environment id must match. When peer includes a persistent id, that id must match too; the per-boot instance id is deliberately ignored. This catches a wrong route, stale service-discovery entry, or endpoint mix-up before the server key is linked or application traffic is sent.

A mismatch rejects with the typed peer_identity_mismatch error. Its context contains the expected and advertised identities and, when the carrier exposes it, a destination with credentials, query, and fragment removed. The connection does not fall through to a lower-security fallback and the automatic redial parks until an explicit retry after the route is fixed.

This comparison verifies the endpoint’s declared runtime name. It is not certificate pinning and it does not prove who owns that name by itself. The signed handshake and trust-on-first-use key pin provide the cryptographic identity check; TLS or encrypted protects confidentiality.

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 wsCarrier(...) source works at any level. 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,
carriers: [
// `secure` defaults to true: this transport runs the handshake at the `encrypted` level above.
wsCarrier(wsUrl),
// `secure: false`: the fallback skips the handshake and sends plain frames.
httpCarrier(httpUrl, { secure: false }),
],
});

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 to keep the two POSTs of a handshake on the same instance:

server.ts
const server = serveChannel(appChannel, {
identity: serverCoord,
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(appChannel, { identity: serverCoord, storage, link, carriers: [httpAcceptorCarrier()], handlers: [appHandler] });