Representative interview topic

Frontend Interview: How Do You Secure Cross-Origin iframe Communication with postMessage?

FrontendHard
Offer.cc Editorial TeamPublished Updated

Question

A host page must communicate bidirectionally with a cross-origin payment iframe through postMessage. The host sends initialization data, while the iframe reports readiness, size changes, and payment completion. How would you prevent malicious pages or the wrong window from forging commands or receiving data, and how would you handle loading, replay, navigation, and failure recovery?

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.origin appears 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 contentWindow locates 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? resize may 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? ready and init can 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:

  1. event.origin exactly equals the full scheme, host, and port;
  2. event.source is the same reference as the iframe's stored contentWindow;
  3. event.data matches 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:

FieldPurposeValidation
vProtocol versionAccept only supported integer versions
typeMessage typeFixed enum; never invoke a dynamic function name
messageIdDeduplication and audit correlationNon-empty, length-bounded, unique within the current window
channelIdBind one successful handshakeCreated by the parent after ready; every later message must match
Business fieldsMinimum data needed by that messageValidate 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:

typescript
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 stateAccepted messagesState afterward
loadingreadyactive
activeresize, cancel, completeSame, closed, or completed
completedNo business messages; optional read-only confirmationRemain completed
closedNoneRemain 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:

TestExpected result
Attacker origin sends a well-shaped completeOrigin check rejects it; no payment query runs
Another iframe on the trusted origin sends a messageSource check rejects it
Correct origin and source send an unknown type or oversized fieldSchema validation rejects it
The same messageId is replayedIt is handled once
An old channel sends a late message after iframe recreationChannel check rejects it
The iframe navigates to another origin before the parent sendsExact targetOrigin causes the message to be discarded
complete carries a missing result or one owned by another userServer authorization and authoritative lookup reject it
ready is late, duplicated, or never arrivesIdempotent 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 expected targetOrigin.
  • Checking only event.data.type on 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 unknown and perform strict schema, range, and state validation.
  • Sending init immediately after page load → the iframe listener may not be registered and the message disappears → let the child announce ready, validate it, then initialize with a timeout.
  • Treating complete as 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.

Public sources

Related questions