Skip to main content

Frame isolation

App frontends are treated as hostile code that happens to be running in the owner's browser. The boundary between them and the kernel is a browser boundary, not a convention.

The default: opaque and credentialless

Tiles, tray pages, and ordinary backgrounds are mounted like this:

<iframe
src={appIndexUrl({canisterId, appId, path, tileId, instanceId, workspace})}
sandbox="allow-scripts"
credentialless="true"
/>

Two attributes carry the weight:

sandbox="allow-scripts" without allow-same-origin gives the frame an opaque origin. It cannot reach the kernel document, sibling app frames, storage keyed to its host, or any same-origin API.

credentialless avoids ambient credential reuse — the frame does not inherit cookies or credentialed storage from its host.

The default opaque app URL uses the compiled https://<app-prefix>--<canister>.icp0.io template; the supported local form is http://<app-prefix>--<canister>.localhost:8000. App-prefixed hosts are restricted to their matching asset subtree. Dedicated backgrounds instead use a prefix containing a browser-local nonce, so their origin is not stable across browser authority generations. Tiles and trays of an app with a dedicated background may be served from the unprefixed canister host, but their sandbox still gives them an opaque origin and they receive no same-origin exception. The SDK authenticates the parent only at the exact production https://<canister>.icp0.io origin or the verified local http://<canister>.localhost:8000 origin. Raw gateway hosts, custom proxy origins, and other local ports are not accepted by this frame handshake.

The dedicated-background launcher performs an explicit browser preflight and fails closed when the selected credentialless/same-origin mode cannot be established. Tile and tray components set credentialless but do not perform that feature probe, so their credential isolation also depends on the browser implementing the attribute. Browser sandbox and origin enforcement are part of the trusted computing base.

Caller identity is parent-owned

When the kernel mounts a frame it registers the iframe's contentWindow with a parent-owned frame context: the installed app id, tile id, instance id, and workspace.

The same values also appear in the iframe's query string, so app UI can display its own context. Those query values are never trusted for request identity.

kernel
app:<appId>:background
app:<appId>:tile:<tileId>:instance:<instanceId>
app:<appId>:tray:instance:<instanceId>

The kernel owns every endpoint id. An app cannot choose its caller id in a request, and there is no field in any payload where it could.

The private port

After an iframe announces readiness, the kernel creates a MessageChannel, keeps port1 with the registered endpoint, and transfers port2 into that exact contentWindow. App code accepts the bootstrap only from window.parent and the exact compiled kernel origin.

Normal traffic then flows over the private port. The kernel associates the port with its stored endpoint context and the complete committed app-instance identity.

Window postMessage is bootstrap-only: it carries the ready probe and the one MessagePort transfer. There is no operational window-message fallback. A non-opaque frame uses its exact registered target origin. An opaque frame requires "*" for the transfer because its origin is "null"; the kernel still sends only to the exact registered contentWindow, and the app authenticates the exact parent window and kernel origin before accepting the port. All tool traffic then uses the source-bound port.

Revalidation after asynchronous work

Security-sensitive asynchronous flows—including long-lived grants, agent invocations, wallet sessions, connection requests, and key-recovery challenges—revalidate their exact endpoint/session and the relevant installed app authority after their awaits.

The consequences:

  • Same-version replacement code cannot retain an old endpoint or session.
  • Uninstall/reinstall additionally allocates a new installation uid.
  • A surface that closes or reconnects during a pending consent request cannot leave a stale request behind.

Dedicated resident origins

An ordinary background is opaque like a tile. A background may instead be granted one of two mutually exclusive dedicated-origin modes, and only a background is ever eligible:

ModeDeclarationFrame attributes
Opaque (default)nonesandbox="allow-scripts" + credentialless
Credentialless-ephemeral dedicateddedicated_resident_originsandbox="allow-scripts allow-same-origin" + credentialless
Persistent dedicatedpersistent_browser_storagesandbox="allow-scripts allow-same-origin", not credentialless

The dedicated modes add allow-same-origin, which is what gives them a real origin instead of an opaque one — and the persistent mode drops credentialless, which is what lets its storage survive. That is precisely why both are install-time disclosures rather than defaults.

Dedicated authority is derived from kernel-owned committed app identity plus a nonce and epoch. The executable initial document is bound by a certified request to the exact Host, the iframe destination, and selected query fields.

Legacy origins, stale identity, top-level navigation, proxy authority, and mismatched subresource destinations all fail closed.

:::caution What disabling cannot do Disabling dedicated authority cannot erase browser storage and cannot retroactively change an already-loaded document. :::

The tray never receives this exception. A tray page is always sandbox="allow-scripts", always credentialless, always opaque — even when the same app's background has approved persistent storage.

Tray lifecycle

The kernel owns the tray button, the numeric badge, the popover chrome, its placement, size caps, native popover focus and light-dismiss behaviour, and the close control.

Clicking mounts the declared page in a new frame with a fresh instance id. Closing the popover destroys the iframe and unregisters the endpoint — so pending requests and grants bound to that session cannot be reused by a reopened tray.

The badge is a private action accepted only from the exact registered background endpoint of an installed app version that declares a tray. Its payload is exactly { badge } — a safe integer 0–9999 or null. It cannot notify, focus, open, reorder, retitle, re-icon, animate, play sound, or alter popover geometry.

Because a cross-origin iframe owns focus while the popover is open, parent keyboard listeners do not receive Escape — the tray page should handle it and dismiss itself.

Message-bus admission limits

The generic bus validates JSON compatibility and a 1 MiB payload limit. It rejects cyclic objects, class instances, typed arrays, undefined, non-finite numbers, and functions.

Tool descriptors reject oversized schemas and invisible or control metadata. Arguments and declared results are validated against draft-07 schemas at both the endpoint and the broker. Calls have timeouts and an eight-call per-caller concurrency limit.

Deeper admission applies finite type depth, value depth, container-element count, binary-leaf count, aggregate binary bytes, encoded-Candid size, decoder-allocation, per-endpoint in-flight, and global in-flight limits. Raw replies are traversed against live Candid before general decoding, so nested vectors cannot force unbounded allocation.

The binary exception, precisely

canister.query_self and canister.update_self may carry Uint8Array leaves — but only at positions the trusted live Candid type proves to be blob / vec nat8. ArrayBuffer is accepted as an input convenience.

The SDK copies each leaf into an immutable snapshot and transfers the copies on the source-bound private port, so caller-owned buffers stay usable. The kernel recursively normalises the value and encodes an ordinary Candid call.

No app-supplied DID, field path, digest, attachment direction, or transport envelope participates in authorization.

Self calls permit at most 512 binary leaves in either direction, within an independent aggregate byte ceiling. Other canister-call routes remain on their own value contract.

Audit

Every routed call records caller, target, tool, timestamp, status, duration, and bounded argument and result summaries in a 200-entry in-memory audit ring. An app can list only its own entries.

Generic audit stores bounded method/outcome and aggregate binary counts and sizes only. Binary bytes, textual encodings, field contents, and content hashes are not persisted — even a digest can correlate private or low-entropy values.

Invariants

  • Third-party frame code is hostile.
  • Only registered sources and ports can invoke kernel bus actions.
  • Endpoint ids and caller context are kernel-attested.
  • Tile, tray, and background roles cannot impersonate each other.
  • Cross-app calls require a per-call or session grant.
  • Metadata, schemas, payloads, results, time, and concurrency are bounded.
  • Model-visible app metadata stays explicitly marked untrusted.

Frame isolation protects kernel credentials and RPC authority; it does not make app code or app-held data confidential. An app can use ordinary browser network APIs and can disclose any value it legitimately receives. The browser, its sandbox implementation, and the configured gateways remain in the trusted computing base.