Cap’n Web is an object-capability system, which gives you strong tools for authorization, but there are a handful of things you must get right yourself.
Authenticate in-band, not with cookies
The WebSocket API in browsers always permits cross-site connections, and does not permit setting headers. Because of this, you generally cannot use cookies nor other headers for authentication.
Instead, we highly recommend authenticating in-band, via an RPC method that returns the authenticated API:
interface PublicApi {
// Authenticate the API token, and return the authenticated API.
authenticate(apiToken: string): AuthedApi;
// Doesn't require authentication.
getUserProfile(userId: string): Promise<UserProfile>;
}// The client never gets an AuthedApi without presenting a valid token.
using api = newWebSocketRpcSession<PublicApi>('wss://example.com/api');
using authed = api.authenticate(apiToken);On the server, authenticate() checks the credential once and returns a new object holding the
result:
class PublicApi extends RpcTarget {
authenticate(apiToken: string): AuthedApi {
let user = verifyToken(apiToken); // throws if invalid
return new AuthedApi(user);
}
}
class AuthedApi extends RpcTarget {
constructor(private user: User) { super(); }
// No token, no re-check. Holding this object is the proof.
getUserId() { return this.user.id; }
}This is the object-capability pattern doing real work: the returned AuthedApi stub is the
authorization. There is no ambient authority to confuse, and no way to call an authenticated method
without holding the capability. Thanks to pipelining, it also costs no extra
round trip.
Yes, this means the server holds state, but only in memory, and only for the lifetime of that one session: the WebSocket connection, or the single HTTP batch. Nothing is persisted, and there is no session store to secure or expire. See Sessions & reconnection.
Rate-limit, because pipelining is cheap for attackers
Cap’n Web’s pipelining can make it easy for a malicious client to enqueue a large amount of work to occur on a server, in a single message.
To mitigate this, implement rate limits on expensive operations. Note that limits applied by a load balancer or gateway will not help here: they count requests or frames, and pipelining makes one frame arbitrarily expensive. The limit has to live in the application.
Two amplifiers worth knowing about:
-
Nested
.map()multiplies. A map over N elements whose callback maps over M produces N × M server-side calls from one client message. Unbounded recursion is less dangerous than it looks: the recording is built on the caller’s stack, so a runaway callback overflows the client first. But a deliberately crafted deep recording is not self-limiting. -
Un-awaited calls accumulate. Every outstanding promise and every stub the peer holds pins an entry in your export table, and the object behind it, for the life of the session. A peer that never settles or disposes anything grows your memory monotonically.
There is no library setting for this.
RpcSessionOptions.limitscovers message size, nesting depth and bigint digits (not reference counts), so if you need a bound on how much one session can pin, you have to enforce it in your own code. Attaching disposers to the objects you return gives you something to count.
If using Cloudflare Workers, also consider configuring per-request CPU limits to be lower than the default 30s. Note that in stateless Workers (that is, not Durable Objects), the system considers an entire WebSocket session to be one “request” for CPU limit purposes.
Set transport payload limits
Cap’n Web applies receiver-side resource limits before expensive message processing, including a
maximum incoming message size before JSON.parse.
If your app is exposed to untrusted peers, also configure native transport or socket payload limits where available:
| Runtime | Option |
|---|---|
Node.js ws |
new WebSocketServer({ maxPayload }) |
| Bun | Bun.serve({ websocket: { maxPayloadLength } }) |
| Browsers / others | The runtime’s built-in WebSocket cap |
Cap’n Web’s own check runs after RpcTransport.receive() has returned a complete message string,
so transport-level limits are still the first line of defence against buffering very large frames.
Types are not validation
Cap’n Web currently does not provide any runtime type checking. When using TypeScript, keep in mind that types are checked only at compile time. A malicious client can send types you did not expect, and this could cause your application to behave in unexpected ways.
For example, MongoDB uses special property names to express queries; placing attacker-provided values directly into queries can result in query injection vulnerabilities, similar to SQL injection. Of course, JSON has always had the same problem, and there exists tooling to solve it.
Can a peer pass a callback where you declared a string? Yes. It will arrive as an RpcStub, your
code will do something surprising with it, and TypeScript will have told you nothing. Validate at
the boundary.
Use capnweb-validate
The companion package capnweb-validate is the recommended answer, and it is
built for exactly this problem. It keeps your TypeScript method signatures as the source of
truth and generates the runtime checks from them at build time, so the boundary is described once
rather than twice:
import { validateRpc } from 'capnweb-validate';
@validateRpc()
export class Api extends RpcTarget {
// Arguments are checked against these types before the method body runs.
getUser(id: string, opts: { includeEmail: boolean }) {
// ...
}
}Two properties make it worth reaching for over hand-written checks:
- Every method is covered. Validation is applied to the class, so a method added next year is checked without anyone remembering to check it. Hand-rolled guards protect only the boundary someone thought about.
- It fails closed. If the decorator is left untransformed because the bundler plugin is not wired up, it throws a configuration error at startup rather than quietly running unvalidated. You cannot ship a service that only looks validated.
See Runtime Validation for setup.
A general-purpose schema library such as Zod works too, and is the better choice if you already validate with one elsewhere in the codebase, or if you need constraints a type cannot express, like “a string of at most 200 characters” or “a positive integer”. The two compose: derive the shape from the types, then apply your own checks to the values. In the future we hope to explore auto-generating type-checking code based on TypeScript types in the core library.
What the protocol does guarantee
Type confusion is your problem, but prototype pollution is not. The protocol hardens two things regardless of what you do:
Object.prototypemembers are unreachable. Any property name that exists onObject.prototype(constructor,__proto__,valueOf,hasOwnPropertyand friends) is blocked both when resolving a property path and when deserializing an object literal. This holds even if the target object has legitimately overridden the name.toJSONis stripped on the way in. It is not anObject.prototypemember, but it would let a peer influence how your values serialize, so an incoming object carrying one has it removed. Note that this applies to deserialization only, not to property paths.- Array paths accept only non-negative integer indices, matching what serialization can produce.
What is reachable on an object depends on what kind of object it is, and the two rules are
opposites: an RpcTarget exposes its prototype members and explicitly refuses instance
properties, while a plain object exposes its own properties only. That distinction decides where
it is safe to put a secret, so read RpcTarget rather than guessing.
private and .map()
private is not private. TypeScript’s private is erased at runtime and does not hide a method
from RPC. Use #-prefixed names for genuinely private members. See
RpcTarget.
Stubs captured by .map() are handed to the peer. Any stubs you use in a .map() callback, and
any parameters you pass to them, are sent to the peer, and a malicious peer can use them for
anything, not just calling your callback. Typically it only makes sense to invoke stubs that came
from that same peer originally. See The magic map().
Reporting vulnerabilities
Please report security issues in Cap’n Web according to the project’s security policy.