Prompt and applicable scenarios
The host runs at https://app.example.com and embeds a payment iframe from https://pay.example.net. After the widget is ready, the host sends locale, theme, and a short-lived payment-session reference. The iframe reports ready, height changes, user cancellation, and completion of the payment flow. The page may contain several same-origin iframes, and the widget may redirect or be recreated while loading.
Design the bidirectional postMessage protocol and frontend implementation. Cover the destination window, receiver identity, message shape, initialization timing, duplicates and reordering, navigation, cleanup, and security tests. A completion message only prompts the UI to refresh; the host server must confirm the final payment state with the authoritative payment system.
This question fits senior frontend, Web platform, frontend architecture, and application-security roles. Its core skills are the browser same-origin policy, cross-document messaging, client trust boundaries, and asynchronous protocol design, so its category is frontend. CORS controls whether browser scripts may read cross-origin network responses. It does not replace postMessage destination constraints or message-listener validation.
What the interviewer evaluates
The first signal is whether the candidate protects both the sender and the receiver. The sender supplies an exact targetOrigin so data is not delivered after the target window navigates to another origin. The receiver checks event.origin on every message and, when it has an expected window reference, checks event.source. Implementing only one half still leaves a disclosure or forgery path.
The second signal is whether the candidate treats messaging as a public entry point with unknown input. Passing an origin check only identifies the origin in which code ran. It does not prove the payload shape, nor does it stop XSS or faulty code on a trusted origin from sending a dangerous command. A strong answer defines a versioned envelope, allowed message types, field constraints, size limits, and state transitions before narrowing event.data from unknown.
The third signal is handling asynchronous timing. An initialization message sent before the iframe registers its listener disappears silently. A reliable flow registers the parent listener first, loads the iframe, lets the child announce ready, and sends init only after validation. Message IDs, a channel ID, timeouts, and idempotent state transitions handle retries, duplicates, and late messages.
Finally, the interviewer looks for a server-side trust boundary. A complete message from a verified iframe is still not proof of payment. The frontend should use a result reference without sensitive details to query the host server. CSP, frame-src, frame-ancestors, and minimal sandbox permissions reduce exposure, but they do not replace message-level identity and data validation.
Questions to clarify first
- Are both origins fixed? Two fixed origins allow exact comparison. If the host supports customer custom domains, the payment page must obtain the allowed parent origin for this session from its server. It must not trust whichever
event.originappears first. - What sensitive data crosses the channel? Theme and height are low risk. Payment credentials, personal data, or reusable bearer tokens should not travel through a broadcast-style interface. If a capability must cross, use a short-lived, audience-bound, revocable, least-privilege reference.
- Who may embed the payment page? The payment page should restrict embedders with
frame-ancestors. If there are many legitimate tenants, generate an accurate policy server-side or use a controlled embedding entry point instead of allowing every site. - How many same-origin iframes can exist on one page? With one, origin plus the expected
contentWindowlocates the peer. Multiple instances need separate window references and channel-bound protocol state; an origin-only broadcast handler is insufficient. - Which messages trigger sensitive actions?
resizemay affect layout directly.complete, refund, order submission, or account changes require server authorization and authoritative state lookup. A message carries a signal, not business authority. - What may be retried after failure?
readyandinitcan be idempotently retried. A resent message must not execute a payment submission twice. Define who owns the message ID, terminal state, and server idempotency key.
30-second answer framework
“I would treat postMessage as an asynchronous API across a trust boundary. The parent registers its listener and stores the iframe's contentWindow before the child sends ready. For every message, the parent verifies the exact origin, expected source, and runtime schema, then replies with init using an exact targetOrigin. The protocol has a version, channel ID, and message ID, while a state machine rejects messages before the handshake, duplicates, reordering, and changes after a terminal state. complete only makes the host query the authoritative payment status through its server. I would test malicious origins, the wrong same-origin iframe, malformed data, replay, and navigation races, then reduce exposure with CSP and minimal iframe permissions.”
Step-by-step deep dive
Step one: map the trust boundary in both directions
Before the parent sends, it has two questions: whether its Window reference belongs to the intended iframe, and whether the target document still has the expected origin when the call runs. targetOrigin addresses the second question. If the target navigated elsewhere, the browser discards the message. Using "*" removes that recipient constraint.
Receiving has two independent questions too: which origin sent the message, and which window at that origin sent it. Any page that obtains a reference to the current window can attempt to send a message. A different iframe on the same trusted origin can also emit an event with the same name. The receiver therefore checks at least:
event.originexactly equals the full scheme, host, and port;event.sourceis the same reference as the iframe's storedcontentWindow;event.datamatches a message shape allowed by the current protocol state.
Do not use suffix containment, a regex fragment, or indexOf to accept origins. https://pay.example.net.attacker.test can pass a loose substring check. Do not add event.origin to an allowlist dynamically either; that turns the attacker's first probe into registration.
Step two: define a small, explicit message protocol
Represent messages as a discriminated union, not arbitrary objects or string commands. A compact protocol can contain:
| Field | Purpose | Validation |
|---|---|---|
v | Protocol version | Accept only supported integer versions |
type | Message type | Fixed enum; never invoke a dynamic function name |
messageId | Deduplication and audit correlation | Non-empty, length-bounded, unique within the current window |
channelId | Bind one successful handshake | Created by the parent after ready; every later message must match |
| Business fields | Minimum data needed by that message | Validate each type, range, and length |
ready has no channelId before a channel exists. The parent receives it through the already verified origin and source, creates a random channelId, and sends init. Later resize, cancel, and complete messages must echo that channel ID. The channel separates an old session and late messages in the same window. It does not replace origin, source, or server authorization.
Start receiver parsing from unknown. This is a simplified TypeScript parent example. parseWidgetMessage represents strict runtime validation, not a type assertion:
const WIDGET_ORIGIN = "https://pay.example.net"
const frame = document.querySelector<HTMLIFrameElement>("#payment-widget")
if (!frame?.contentWindow) {
throw new Error("Payment iframe is unavailable")
}
const widgetWindow = frame.contentWindow
let channelId: string | null = null
const seenMessageIds = new Set<string>()
let state: "loading" | "active" | "completed" | "closed" = "loading"
window.addEventListener("message", onWidgetMessage)
frame.src = "https://pay.example.net/embed"
function onWidgetMessage(event: MessageEvent<unknown>) {
if (event.origin !== WIDGET_ORIGIN) return
if (event.source !== widgetWindow) return
const message = parseWidgetMessage(event.data)
if (!message || seenMessageIds.has(message.messageId)) return
seenMessageIds.add(message.messageId)
if (message.type === "ready") {
if (state !== "loading") return
channelId = crypto.randomUUID()
widgetWindow.postMessage(
{
v: 1,
type: "init",
messageId: crypto.randomUUID(),
channelId,
locale: "zh-CN",
theme: "system",
checkoutSessionRef: "short-lived-opaque-reference",
},
WIDGET_ORIGIN,
)
state = "active"
return
}
if (state !== "active" || channelId === null || message.channelId !== channelId) return
if (message.type === "resize") {
frame.style.height = `${Math.min(Math.max(message.height, 240), 900)}px`
}
if (message.type === "complete") {
state = "completed"
void refreshAuthoritativePaymentStatus(message.resultRef)
}
}The real parser also rejects unrecognized dangerous types, oversized strings, non-finite numbers, and state combinations that are not allowed. Do not use as WidgetMessage to skip runtime checks. Structured clone can carry objects; it does not prove that an object satisfies the application protocol.
The child applies the same rules in the other direction. It accepts only a host origin configured in advance or bound by the server-side session, requires event.source === window.parent, stores the channel ID only after validating the init schema, and always replies with the exact host origin. Neither peer may omit validation merely because it expects only one counterpart.
Step three: remove timing ambiguity with a handshake and state machine
The HTML Standard warns that a newly navigated child document may not have installed its message listener yet. A parent message sent then does not wait in a queue for the child to become ready. The parent installs its listener before setting or confirming the iframe URL. The child installs its listener and announces ready. The parent sends sensitive initialization only after ready passes origin, source, and schema checks.
The state machine makes allowed actions explicit:
| Current state | Accepted messages | State afterward |
|---|---|---|
loading | ready | active |
active | resize, cancel, complete | Same, closed, or completed |
completed | No business messages; optional read-only confirmation | Remain completed |
closed | None | Remain closed |
The parent can put a timeout on the handshake. On timeout it shows a retryable error and destroys the old iframe. Recreating the iframe requires a new channel ID, an empty deduplication set, an updated expected window reference, and removal of the old listener. Reusing the old channel would let a late message from the previous page enter the new session.
Step four: separate duplicate messages, duplicate business actions, and server facts
Message delivery and business execution require two layers of idempotency. The frontend uses messageId to ignore a duplicate UI message and a state machine to reject changes after a terminal state. The server still needs an order or payment-attempt idempotency key to prevent a duplicate charge, because refreshes, network retries, and multiple tabs can bypass the frontend set.
complete means only that a trusted window claims the flow finished. The parent sends resultRef to its own server. The server verifies the current user, order ownership, amount, and authoritative payment-provider state before returning a displayable result. Even if XSS controls scripts on the payment iframe's origin, a forged complete cannot directly mark the order paid.
Message logs contain only protocol version, type, a channel hash, outcome, and rejection reason. They do not contain payment credentials, full personal data, or reusable tokens. Bound the deduplication set or destroy it with the channel so an attack or a long-running page cannot grow memory indefinitely.
Step five: handle navigation, multiple instances, and shared origins
event.origin is the sender's origin when it called postMessage. It does not guarantee that the sending window has the same current or future origin. Revalidate every message rather than trusting the window forever after one handshake. If the target iframe navigates to an attacker origin, an exact targetOrigin stops subsequent parent messages, and the parent receiver rejects new messages because the origin differs.
If the iframe navigates to a different application on the same trusted origin, the origin check still passes. Source, channel ID, protocol version, message types, and server confirmation can limit old sessions, misrouting, and business impact, but they cannot resist malicious code on that origin. The browser treats the whole origin as one security principal. Applications at different trust levels must use different subdomains; URL paths and message fields cannot isolate them.
With several payment iframes on one page, keep a separate contentWindow, state, channel, and handler record for each element. One global listener may route to instances, but it must first map the event.source reference to an instance. It must not begin by trusting an instance ID supplied inside the message.
Step six: restrict iframe capabilities and embedding relationships
The host CSP's frame-src permits only the approved payment origin. The payment site's frame-ancestors permits only approved hosts to embed it. The iframe's sandbox enables only capabilities the payment flow actually needs, such as scripts, forms, or specific popups. Every added permission should correspond to a testable requirement.
These controls answer who can load whom and which browser capabilities the embedded document has. They do not authenticate a message or make "*" safe. If any site may embed an authenticated payment widget, an attacker can load it in an attacker-controlled page and actively send commands. frame-ancestors directly narrows that path.
If the peers need a high-frequency independent stream after the handshake, the authenticated first postMessage can transfer a MessagePort. Subsequent communication then uses a dedicated port, reducing interference among global message listeners. Initial port delivery still requires origin, source, and schema checks, and possession of a port still grants no server-side business authority.
Step seven: prove rejection paths with negative tests
A positive test proves only that the legitimate page works. Security verification constructs inputs that must be rejected:
| Test | Expected result |
|---|---|
Attacker origin sends a well-shaped complete | Origin check rejects it; no payment query runs |
| Another iframe on the trusted origin sends a message | Source check rejects it |
| Correct origin and source send an unknown type or oversized field | Schema validation rejects it |
The same messageId is replayed | It is handled once |
| An old channel sends a late message after iframe recreation | Channel check rejects it |
| The iframe navigates to another origin before the parent sends | Exact targetOrigin causes the message to be discarded |
complete carries a missing result or one owned by another user | Server authorization and authoritative lookup reject it |
ready is late, duplicated, or never arrives | Idempotent handling or timeout enters a retryable failure state |
Code review also searches every postMessage call for "*", every message listener, fuzzy origin comparisons, unchecked event.data, and paths that write message data to innerHTML. Browser integration tests cover listener teardown, iframe recreation, multiple instances, and route changes.
High-quality sample answer
“I would split the channel into a sending boundary and a receiving boundary. The parent sends only to the iframe contentWindow it stored and uses https://pay.example.net as the exact targetOrigin. For every incoming message, the listener compares the full event.origin and that same contentWindow reference before runtime schema validation. Origin alone is insufficient because the page can contain several same-origin iframes. Source alone is insufficient because that window might have navigated.
I would define a small versioned set of messages: ready, init, resize, cancel, and complete. The parent registers its listener before loading the iframe. The child installs its listener and sends ready; only after validation does the parent create a channel ID and send initialization. Every later message carries the same channel ID and a unique message ID. A state machine and deduplication set reject messages before the handshake, duplicates, reordering, and state regression after completion. A handshake timeout destroys the old iframe, while retry uses a new window reference and channel.
A completion message never changes the order directly. It carries only an opaque result reference. The host server validates the user and order and queries the payment system for authoritative status. A bug or even XSS on the trusted payment origin therefore cannot forge payment with one message.
I would also restrict the component with host frame-src, payment-page frame-ancestors, and minimal sandbox permissions. Tests would cover the legitimate flow plus attacker origins, the wrong same-origin iframe, old channels, replay, iframe navigation, and ready timeouts. Every rejection path must avoid sensitive business actions.”
Common mistakes
- Sending with
"*"→ the target window may have navigated to an attacker page and still receive sensitive data → always use the exact expectedtargetOrigin. - Checking only
event.data.typeon receipt → any page with a window reference can forge the named command → verify exact origin and expected source before parsing data. - Accepting an origin by substring or domain suffix fragment → an attacker domain can contain the trusted text → compare the full normalized origin supplied by the browser.
- Processing after a TypeScript assertion → types do not exist at runtime, so malformed data still enters logic → begin with
unknownand perform strict schema, range, and state validation. - Sending
initimmediately after page load → the iframe listener may not be registered and the message disappears → let the child announceready, validate it, then initialize with a timeout. - Treating
completeas proof of payment → a frontend message is not an authoritative business fact, and even a trusted origin can be compromised → authorize on the host server and query authoritative payment state. - Stopping origin checks after the handshake → a window can navigate during the session and carry old trust across documents → validate every message and isolate a recreated session with a new channel.
- Assuming CORS or sandbox already secures messages → they constrain network reads and document capabilities, not message identity or data → retain message-level checks and use browser policy as defense in depth.
Follow-up questions and responses
Follow-up one: The widget must support thousands of customer custom domains. How does the child know the parent origin?
Do not automatically trust the first message's event.origin. The host first creates an embed session with the server. The server verifies ownership of the customer domain and binds the allowed parent origin to a short-lived session. The payment page obtains the expected origin from that session and uses only that value for sending and receiving. Session expiry, a domain change, or window recreation requires reauthorization. A custom domain that cannot be proven must not receive sensitive embedding capability.
Follow-up two: Several same-origin iframes need messaging. Can channelId alone route them?
The handler cannot begin by trusting a channel declared by the message. A global listener first uses event.source to find the instance in a registry of known Window references, then checks that instance's origin, state, and channel ID. The channel rejects old-session and out-of-order messages; the window reference identifies the browsing context. They serve different purposes.
Follow-up three: The iframe visits a login page and then a checkout page on the same payment origin. How does the handshake work?
Login and checkout on one origin are the same browser security principal; the parent cannot learn the path from event.origin. If they are equally trusted, every iframe load makes the parent discard the old channel and return to loading. It handshakes again only after the new document sends a ready that matches the session contract, which rejects late messages from the previous document. If login must not see initialization data or has a lower trust level, move it to another origin and give checkout a dedicated embedding entry point. A channel ID cannot repair missing same-origin isolation.
Follow-up four: Do you still check origin after switching to MessageChannel?
Port messages do not carry the same origin field as window messages, so security depends on who initially received the port. The first postMessage that transfers it must verify exact origin, source, and handshake state. The port belongs only to the current session and is destroyed on closure. A dedicated port reduces misrouting, but it does not replace initial authentication, message schemas, or server authorization.
Follow-up five: The iframe controls automatic height changes. How do you stop it from stretching the page to an extreme size?
Even an identity-verified resize is untrusted business input. The protocol accepts only finite integers; the parent clamps them to product-defined minimum and maximum heights and rate-limits updates. It records rejection reasons for out-of-range or excessive messages. If content truly needs more space, use internal scrolling or define a new product limit instead of giving the child arbitrary CSS control.