Iframe & Token Lifecycle
Mount one-time bootstrap URLs and renew active sessions in place
Your backend issues a bootstrap URL through
POST /partner/embed-sessions. The
parent receives no bearer token:
{
"id": "6dbfd424-3c66-450c-b11d-f5099b0e70f7",
"channelId": "cc0528eb-8a9e-48ac-96fd-743101caac97",
"parentOrigin": "https://app.hostaway.com",
"embedUrl": "<embed-origin>/reports/trial-balance?frameAncestorToken=<policy>#code=<code>",
"teamId": "4f8f94de-2cc0-4ec7-a7f8-f0c7f560e59a",
"userId": "018f9c2b-7f4b-7d72-bf2e-9ab983f07018",
"bootstrapExpiresAt": "2026-07-19T12:01:00.000+00:00",
"sessionTtlSeconds": 900
}Mounting
Set embedUrl directly as the iframe src. Do not parse either credential,
move the fragment code, or call an embedded endpoint from the parent:
<iframe
src="<embed-origin>/reports/trial-balance?frameAncestorToken=<policy>#code=<code>"
title="Profit and loss"
></iframe>Frame-Ancestor Policy
Before the iframe HTML is served, the embedded-application host submits the
short-lived query credential to
POST /embed-sessions/frame-ancestor-policy over its private API connection.
The API returns the session's exact embedOrigin and parentOrigin. The host
verifies the embed origin and emits one response header:
Content-Security-Policy: frame-ancestors https://app.hostaway.comThe response is no-store. Missing, invalid, expired, cross-origin, or
already-exchanged policies fail closed with frame-ancestors 'none'; the
embedded app is not served. The partner page must not call the policy endpoint
or synthesize this header itself.
allowedEmbedParentOrigins is the API-token-level upper bound. The exact
parentOrigin chosen for this session becomes the browser-enforced CSP, so
different approved partner domains can receive different policies without an
embedded-application deployment.
Exchange
The iframe calls POST /embed-sessions/exchange from the embedOrigin
selected during issuance. One successful exchange:
- rejects every other browser origin;
- consumes the single-use code and activates the session;
- returns a 15-minute bearer with the server-bound
channelIdandparentOrigin; and - ends with the embedded app clearing both bootstrap credentials from its visible URL.
The bearer stays in memory, bound to one session, partner, team, user,
audience, and scope set. bootstrapExpiresAt is the 60-second code deadline;
sessionTtlSeconds is the bearer lifetime starting at exchange.
A plain iframe reload cannot restore access: the code is spent and the cleared URL holds no session, so a reload needs a new bootstrap URL and mount. A running iframe renews in place through the channel described below.
Removing the iframe only clears its in-memory token. Server-side invalidation
is DELETE /partner/embed-sessions/{id} from your backend, on logout,
account switching, permission changes, and incident response. The API checks
the session record on every bearer request, including GET /me.
Establishing the Channel
Use window.postMessage only to transfer one dedicated MessageChannel port:
- After successful exchange, the iframe sends
channel.requestto the exact returnedparentOrigin. - The parent validates the iframe origin,
event.source,channelId, protocol, version, and request ID. - The parent creates a
MessageChanneland sendschannel.connectto the exact iframe origin while transferring one port. - The iframe validates the parent origin,
event.source, channel ID, andreplyTobefore accepting that port. - All lifecycle messages then use the port, not global window messaging.
Never use targetOrigin: "*". Track the expected contentWindow separately
for every iframe so two instances cannot claim each other's channel.
The runtime schemas and TypeScript types are exported from @vrplatform/api
as embedLifecycle. Every strict envelope includes:
{
"protocol": "vrplatform.embed.lifecycle",
"version": 1,
"channelId": "cc0528eb-8a9e-48ac-96fd-743101caac97",
"type": "ready"
}Unknown types, extra fields, malformed identifiers, and incompatible versions must not reach application handlers.
Lifecycle Messages
The iframe sends:
readyafter authentication and initial view rendering.errorwith a sanitized code, recoverability, and correlation ID.session.expiringwith an expiry andrequestId.session.renewedwith the replacement expiry andreplyTo.resizewith a positive integer content height when automatic sizing is enabled.
The parent sends session.renew with a new requestId, sets replyTo to the
expiring message's requestId, and includes the complete successor embedUrl.
Messages never contain a bearer token, partner key, user data, team data,
report data, or filters.
In-Place Renewal
- Receive
session.expiringfrom the iframe. - Ask your backend to call
POST /partner/embed-sessions/{id}/renewals. - Send the returned complete
embedUrlinsession.renew. - The iframe validates the URL against its own origin, exchanges the new code once, and keeps the old bearer until exchange succeeds.
- A successful exchange activates the successor and revokes the predecessor
atomically. The iframe keeps its current route, query filters, and rendered
report state, then sends
session.renewed. - A rejected renewal produces
errorand leaves the predecessor usable until its existing expiry.
Initial bootstrap failures happen before the iframe knows the server-approved parent origin and channel ID. They remain visible inside the iframe and are not sent through an unauthenticated pre-exchange channel.
Frontend Flow
- Ask your backend for a bootstrap URL.
- Mount the iframe before the 60-second bootstrap expiry.
- Establish the dedicated channel and wait for
ready. - Renew in place before the 15-minute bearer expires.
- On access or team change, revoke the old session, discard the iframe, and create a new identity-bound session.
Do not persist the URL, policy token, code, or session token in local storage or telemetry, and never use the partner API key in the browser.
See UI States for shell behavior.
