SSC security model
Server Side Computed (SSC) components split your app across a trust
boundary: host components run in your Go server process, the wasm client
runs in the user’s browser, and a WebSocket connects them. This page
documents exactly what crosses that boundary, what never does, and which
security responsibilities remain yours. Everything here is grounded in the
implementation (host/, hostclient/, ssc/).
What travels over the wire
All SSC traffic flows over a single WebSocket, served at /ws by
host.NewMux (or ssc.NewSSCServer). Messages are JSON.
Client to server (host.Inbound):
{
"component": "HelloHost",
"payload": { "cmd": "increment" },
"sequence": 4,
"ack": 7,
"resumeToken": "<opaque token>"
}
The client sends: an {"init": true} payload for each bound component on
connect, whatever payloads your client code passes to hostclient.Send,
and automatic resync requests when hydration detects a mismatch.
Server to client (host.Outbound):
{
"component": "HelloHost",
"payload": { "greeting": "hello" },
"session": "<id>",
"sequence": 8,
"ack": 4,
"resumeToken": "<opaque token>"
}
The payload is one of:
- Host variable values. Whatever your handler returns is
json.Marshaled and sent as-is. The client applies each key to the
matchingdata-host-varelement (as text) and to the matching host
signal (t.HInt,t.HString, …).
- An init snapshot (
host.InitSnapshot): a rendered HTML fragment plus
a list of variable names, sent in response to aresyncrequest. The
client injectssnapshot.HTMLinto the component root wholesale.
- Delivery metadata. Session ID, resume token, sequence, and
acknowledgement fields support ordered delivery and reconnect replay.
Typed actions add an action name, request ID, result, or public
host.ActionError. The wire does not carry code, hidden store contents, or
state that a handler did not explicitly return.
What stays on the server
- Your Go code. Host handlers are never serialized; only their return
values are. Business logic, queries, and validation are invisible to the
client.
- Secrets and handles. Database connections, API keys, and anything
else living in the host process never crosses the wire unless a handler
puts it in a return value. The corollary: everything a handler returns
becomes public. Do not return raw database rows or internal structs;
build the payload explicitly.
- Session state. Each new WebSocket identity gets a
host.Sessionwith
an isolatedstate.StoreManagerand a context bag
(ContextGet/ContextSet). None of it is sent to the client except the
random session ID and resume token. Detached state remains available for
the configured resume lifetime. Note thatstate.GlobalStoreManageris
shared across all sessions; keep per-user data in the session’s store
manager, never in global stores.
One caveat about the client side of the boundary: the wasm binary ships to
the browser. Any string compiled into client code (tokens, endpoints,
“hidden” logic) is extractable. Treat client Go code exactly like you would
treat JavaScript.
Authentication and authorization
This is the most important paragraph on this page. As implemented today:
- By default the
/wsendpoint accepts any connection.wsHandler
allocates a session after the upgrade guards pass. There is no login or
Origincheck unless you enable the guard options below.
- Legacy host components are addressed by name in a global registry. Any
connected client can send any payload to any registered component. The
component name in a message is data chosen by the client, not a routing
decision you made.
- The session ID and resume token identify transport state; neither proves a
user identity. Both are generated withcrypto/rand, but they belong to
whoever established the connection.
Apply these controls together:
- Gate the upgrade with the built-in guards.
host.NewMuxand
ssc.NewSSCServeraccepthost.WithOriginAllowlist(...)and
host.WithAuthFunc(...):
mux := host.NewMux(root,
host.WithOriginAllowlist("https://app.example.com"),
host.WithAuthFunc(func(r *http.Request) bool {
return validSessionCookie(r)
}),
)
The allowlist rejects upgrades whose Origin header is missing or
unlisted (403) before a session is allocated; the auth func sees the
full upgrade request (cookies, headers) and returning false rejects
with 401. Both default to off. Your own middleware in front of the mux
(or a reverse proxy enforcing auth and an Origin check) works too.
2. Bind identity to the session. Use
host.WithSSCSessionInitializer to copy verified request identity into the
session context before its first message:
host.WithSSCSessionInitializer(func(r *http.Request, session *host.Session) error {
session.ContextSet("user", authenticatedUser(r))
return nil
})
- Authorize messages and actions.
host.WithSSCAuthorizercan reject
every decoded message before dispatch.host.WithActionAuthorizerapplies
a typed policy after an action request is decoded. Legacy component
handlers should still perform their own object-level checks.
Threat notes
- All client input is untrusted. Legacy handler payloads are
map[string]anydecoded from client JSON. The client is free to send
payloads your UI would never produce: unexpected keys, wrong types,
hostile values, messages for components the user never rendered.
Validate types and ranges in the handler, on the server, every time. Typed
actions usejson.Decoder.DisallowUnknownFields, but tags and field types
are only structural validation; validate ranges and business rules too.
Client-side validation in wasm is UX, not security.
- Escape what you render. The server-side HTML helpers (
host.Span,
host.Div,host.P,host.Tag) HTML-escape the value by default, so
user-derived data in a host variable’s initial render stays text.
InitSnapshot.HTMLis still injected into the DOM as raw HTML on the
client: build snapshots from the escaping helpers, and reach for
host.RawTag(unescaped value) orhost.Raw(unescaped fragment) only
with markup you generated or sanitized yourself. Subsequent
host-variable updates are applied as text content and are safe from
injection.
- Broadcast scope.
host.Broadcast(name, payload)sends to every
connection subscribed to that component. Use
host.WithSessionTarget(sessionID)for per-user data; broadcasting a
payload that contains one user’s data sends it to all users on that
component.
- Transport security. A resume token can reattach detached session state.
Treat it as a bearer credential, do not log it, and usewss://.
host.Startserves HTTP and, on the next port,
HTTPS with a self-signed certificate generated at boot. That certificate
is a development convenience. In production, terminate TLS with real
certificates (typically at a reverse proxy) so the WebSocket runs over
wss://; otherwise session IDs and payloads travel in cleartext.
- Resource exhaustion. The default endpoint caps frames at 1 MiB,
connections at 4096, retained sessions at 8192, messages at 600 per session
per minute, typed action execution at 15 seconds, and replay history at 256
messages. Override these
values withhost.WithSSCLimitsfor the deployment. Keep proxy limits too.
Action handlers should observe their context; a handler that ignores
cancellation may continue running after the timeout response.
rfw keeps application code and private state on the server, but it does not
ship an identity provider. Treat every inbound message as hostile until the
upgrade guard, session initializer, and authorization policy have established
the caller and permitted the operation.