Security
A realm inherits its connection's security level and can require a minimum — declared once, enforced on both ends. Plus the caveats that matter- at-rest storage, wire-visible rejections, replay, and TOFU.
A realm carries avatar data — identities, positions, presence, whatever your state tree holds — and it moves that data over your app’s connection. So a realm’s confidentiality and authenticity are exactly the connection’s: the realm rides the same secured socket the action plane uses, negotiated once at the handshake. There is no separate realm crypto to configure.
That means everything in Security Levels applies verbatim — the three levels (none / authenticated / encrypted) — as does Identity & Trust: how authenticated proves identity with a signed challenge, and the trust-on-first-use key pinning. Read those first; this one is only what the realm layer adds on top.
Declare a minimum: security
Section titled “Declare a minimum: security”A realm can require a floor. The connection is where the level is chosen, but a realm shouldn’t have to trust that every caller chose well — so it declares the least it will accept, and both ends enforce it:
export const gameRealm = defineRealm({ id: "game_realm", security: "encrypted", // "authenticated" (default) | "encrypted" avatars: { /* … */ }, state: { /* … */ }, rules: (r) => [ /* … */ ],});Two members, no "none": a realm always requires at least an identity-bound (authenticated) connection — an unauthenticated realm has no idea who is writing, which no rule could survive. authenticated is the default, so a realm that says nothing already refuses a plain connection.
The declaration is not part of the schema hash — it’s a policy floor, not a wire-compat fact, so raising or lowering it never forces a client migration.
Enforced on both ends, fail-closed
Section titled “Enforced on both ends, fail-closed”The floor is checked twice, independently:
- The server rejects an under-secured attach. A
helloarriving on a connection below the floor is answered with aninsufficient_securityrejection and no hydrate — no avatar data leaves the server for a connection that can’t carry it at the required level. This holds across a hibernation wake too: the reconstructed host re-reads the connection’s negotiated level from the persisted socket attachment and enforces the floor identically. - The client refuses to send. Because the server “rejecting” still means the client already sent its
hello(identity, and then its writes), a misconfigured client is stopped locally before the first frame:connectRealm/connectRealmReplicafail the attach —whenLive()rejects,attachErrorcarriesinsufficient_security, and not one realm frame is emitted onto the wire. This is what keeps avatar data from leaving the device as app-layer plaintext when someone points anencryptedrealm at anauthenticatedconnection by mistake.
Overrides only raise, never lower
Section titled “Overrides only raise, never lower”You can tighten the floor for a particular deployment or connection without editing the definition:
// Server: raise the floor this host enforces (e.g. a PII deployment of a shared realm).hostRealm(gameRealm, { security: "encrypted", resolveAvatar, engine });
// Client: raise the bar this connection asserts before its first hello.connectRealm(gameRealm, { avatar, connection, security: "encrypted" });Both are clamped to ≥ the definition’s declared level — an override can only raise the requirement, never weaken it. The two sides are enforced independently: a hostRealm floor above what a client checked still rejects at the server (a client validating only the realm’s declared minimum would have sent), so the server’s floor is a real backstop, not advice.
One connection, many realms — take the max
Section titled “One connection, many realms — take the max”Several realms can share one connector (one handshake, one identity). A connection’s security level is fixed at the handshake and cannot be upgraded while live, so establish the connection at the maximum of every realm’s minimum it will carry. A realm whose floor exceeds the connection’s negotiated level is a hard connectRealm / connectRealmReplica failure — never a silent downgrade or a renegotiation.
Prefer encrypted for replicas
Section titled “Prefer encrypted for replicas”A replica is the highest-value connection in the system: its avatar type’s rules typically grant it a broad — often “everything” — view, so its socket carries the most state. Treat it accordingly and require encrypted for replica connections handling anything sensitive, even when your client realm runs authenticated.
Caveats worth internalizing
Section titled “Caveats worth internalizing”Transport security is not at-rest encryption
Section titled “Transport security is not at-rest encryption”encrypted seals every frame in flight. It says nothing about storage. A realm served on a Cloudflare Durable Object keeps its authoritative state in the DO’s SQLite, which is cleartext at rest. For state that must not persist in the clear:
- prefer
persistence: "ephemeral"— nothing is written to disk; the realm lives only in memory and cold-starts from genesis (pair it withassertOnAttachso clients re-assert their entries on a wake); - or
migrate: "wipe"plus a shortexpireAfterIdleMs, so an idle realm’s storage is reclaimed quickly rather than lingering.
The principle: minimize what sensitive data is persisted and for how long. Field-level encryption of specific values (before they enter the state tree) is an app concern — the library encrypts the pipe, not your columns.
Rejections are wire-visible — never put secrets in an error
Section titled “Rejections are wire-visible — never put secrets in an error”A rule or intent rejection is sent to the client as the error’s public JSON (toJsonObject() — its id, message, and context). The engine never serializes your serverContext, but it faithfully sends whatever you put in the error. So a rule error’s message and context — and, like any @nice-code/error, its stack — are readable by the client. Treat them like the client bundle: don’t embed server secrets, internal ids, or ctx values you wouldn’t hand to the user in a rejection.
authenticated is trust-on-first-use, not a CA
Section titled “authenticated is trust-on-first-use, not a CA”Identity pinning happens the first time two peers meet and is enforced on every subsequent connection — strong against an outsider who never held the key, but it is not certificate-authority-grade PKI. If first contact happens over a hostile network, the pin is of whoever answered. This is the same TOFU model Identity & Trust describes; it’s called out here because a realm’s whole authority model rests on the avatar identity that pin establishes.
Replay is neutralized — but keep intents idempotent
Section titled “Replay is neutralized — but keep intents idempotent”The engine guards against replayed and duplicated frames: a resent write (same client sequence) is re-acknowledged, not re-applied, and that guard survives reconnects and hibernation wakes. You don’t implement replay protection.
You should still make intents naturally idempotent, because at-least-once delivery means a retry after a lost ack can legitimately re-present an intent the server already applied. The pattern is a client-generated id the apply keys on:
// Client mints the id; a retry re-sends the SAME id.realm.intents.purchase({ purchaseId: newId(), itemId });// Server apply: already applied ⇒ commit as a no-op (no double-spend).apply: ({ state, avatar, input }) => { if (state.orders[avatar.persistentId]?.[input.purchaseId] != null) return true; // …charge, decrement stock, write the order under input.purchaseId…},An intent that isn’t idempotent turns a dropped ack into a double effect — a correctness and security problem (double-charge, double-grant). See Writes & Intents.
Record keys and state size
Section titled “Record keys and state size”Two more the engine handles or delegates:
- Prototype-pollution keys (
__proto__,constructor,prototype) can never be dynamic record keys — the engine rejects a frame (or an intent) that tries, on both apply and client projection. Nothing to do. - State size is bounded per frame, not cumulatively. Ingress limits cap any single write; they do not cap how large the authoritative tree grows across many legitimate writes. Bounding writable-record cardinality — a ceiling on entries per record — is an app rule today (there is no
maxStateBytesengine knob). If a record’s key space is attacker-influenced, cap it in a rule.
The short version
Section titled “The short version”- A realm inherits its connection’s level; declare a minimum with
security(defaultauthenticated; nonone). - The floor is enforced both ends, fail-closed — the server refuses to hydrate, the client refuses to send. Overrides only raise it.
- One connection carries realms at the max of their minimums; a shortfall is a hard failure, not a downgrade.
encryptedis in-flight only — mind at-rest storage (ephemeral/wipe for sensitive state), keep secrets out of rejections, and make intents idempotent.