Security & Origins
The marketplace embeds untrusted, publisher-authored games. The boundary between your game and the shell is a hard security boundary, and the SDK is built to respect it. This page explains the trust model so you know exactly what crosses the line — and what never does.
The origin model #
Your game is served from its own origin, https://g-{gameId}.carverjs.dev, inside a sandboxed <iframe>. Because every game gets a distinct origin, the browser's same-origin policy isolates each game's storage, cookies, and scripts from every other game and from the shell.
All communication is postMessage:
Outbound (game → shell). The shell validates the sender origin with an exact string match against your game's origin — never a prefix or suffix check — and rebuilds every field from scratch, clamping numbers and truncating strings. A malformed message is silently dropped, never an error.
Inbound (shell → game). The SDK only delivers messages whose
sourceis the direct parent window. Siblings, nested frames, and unrelated windows are ignored. Since 1.1.0 this direction carries real configuration — including secrets — and the parent-window check alone tells you that something embeds you, never which page does. See The inbound channel carries secrets.
Outbound messages carry no secrets #
ready, progress, score, event, error, requestFullscreen, and exit are posted with targetOrigin: "*" by default. That is intentional: they carry no secrets, and a sandboxed game cannot know which shell origin embeds it (production, staging, or a local preview). There is nothing to leak.
The v2 additions keep that property. carver:init-ack and carver:ice-expired carry a protocol version and nothing else — iceExpired() reports that credentials stopped working, never the credentials. carver:telemetry is secret-free by construction: its closed tuple has nowhere to put a token, a device id, or a raw timestamp, and telemetry() copies the known keys out by hand so a variable carrying extra properties cannot smuggle them along.
That is the whole reason targetOrigin: "*" is defensible. If your game only ever ships to a single shell you can still pin the origin — pin both directions at once, as shown in the next section.
The inbound channel carries secrets #
Protocol v2 opens the shell → game direction, and that direction carries real credentials: a sessionToken for your own backend, a TURN credential, a room.signalingToken. This is the point of v2. The shell already holds those values and your game needs them to reach its own backend, so handing them over beats baking them into your bundle.
Secrets run one way. They must never travel game → shell, and they do not: every outbound message, v1 and v2, is still free of them.
By default the only inbound check is source === window.parent. That check is sound — nothing but the real embedder can be that object — but it does not identify which page the embedder is. Your published game is loadable by anyone, so anyone can iframe it, and by framing it their page is window.parent. It can then forge a carver:init and choose your signaling backend, your TURN servers, and your API base URL.
carver.configure({
targetOrigin: "https://carverjs.dev",
parentOrigin: "https://carverjs.dev",
});event.origin is set by the browser and cannot be forged, so parentOrigin is the fence between "the shell said so" and "somebody said so". Call configure() at boot, before you subscribe.
A game that never consumes carver:init has nothing to pin — no configuration arrives, so there is nothing to forge at it. A game that does consume it must pin.
The native bridge #
Over a native WebView the transport changes, and so does the trust boundary. There is no parent frame; the shell delivers inbound messages by calling window.__carverShellDeliver, a global the SDK installs on your game's own document the first time anything subscribes.
Anything that can call it already runs as your game. There is no origin to check — only a shape, and the SDK checks that: a value that is not an object (after parsing JSON text) or whose type is not a carver: string is dropped. So the bridge is not a new remote attack surface, and parentOrigin has nothing to filter there, because nothing crossed a frame boundary to arrive. See The native bridge.
Identity tokens #
getIdentity() is the one place a sensitive value crosses into your game — a signed token proving which player is playing. Three properties keep it safe:
The token is minted by the shell from the signed-in session, not assembled by the game. Your game cannot forge one.
The shell delivers it pinned to your exact game origin — never
"*"— so it cannot be intercepted by a different frame.The token is short-lived and signed (RS256). It is useless to anyone who cannot present it before it expires, and tampering breaks the signature.
The token is for your backend. Always verify it server-side against the public JWKS:
https://www.carverjs.dev/.well-known/jwks.jsonAnything the client can read, the client can fake. The signature only means something once your server has checked it against the marketplace's public keys. Use the verified sub claim — not anything the game tells you — to decide who the player is. See Player Identity.
Sandbox and permissions #
The shell runs your game with a deliberately narrow sandbox and permissions policy:
Scripts, pointer lock, fullscreen, and in-frame forms are allowed.
Pop-ups, top-level navigation, and reaching into the parent's storage scope are not.
Cameras, microphones, geolocation, USB, and MIDI are denied — a game cannot prompt for them even if it tries.
Build for those constraints. If your game needs network access to your own backend (for example to save data behind an identity token), make sure your game's hosting and your API allow the request — the marketplace does not proxy it for you.
Treat inbound data as untrusted, too #
Inbound messages are real traffic now, not a reserved channel. Switch on known type values, ignore everything else, and narrow a payload to the fields you actually use before you use them.
carver.subscribe((msg) => {
switch (msg.type) {
case "carver:pause":
pauseLoop();
break;
case "carver:resume":
startLoop();
break;
// ignore unknown types — forward-compatible by default
}
});
carver.onInit((msg) => {
if (msg.type !== "carver:init") return; // a carver:ice refresh
const { locale, api } = msg.payload;
setLocale(typeof locale === "string" ? locale : "en");
if (typeof api?.baseUrl === "string" && api.baseUrl.startsWith("https://")) {
setApiBase(api.baseUrl);
}
// payload.extra is shell-specific and unvalidated — narrow it the same way.
});Every field of carver:init is optional, so a missing one is normal and an unexpected one is not. Never feed message data into innerHTML, eval, or a query without sanitizing it — player.displayName and extra are the fields most likely to end up in your DOM.