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:
| Mode | Declaration | Frame attributes |
|---|---|---|
| Opaque (default) | none | sandbox="allow-scripts" + credentialless |
| Credentialless-ephemeral dedicated | dedicated_resident_origin | sandbox="allow-scripts allow-same-origin" + credentialless |
| Persistent dedicated | persistent_browser_storage | sandbox="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.