Skip to content
blog
3 min read

postMessage doesn't reach a native WebView host

Why window.postMessage silently fails from inside an Android/iOS WebView, and the two real bridges that replace it.

Follow-up to the iframe embedding notes — one thing I glossed over there: WebView is not an iframe. It doesn’t just have different header rules, it doesn’t have a window.parent to send a message to at all. Worth its own note.

Why postMessage goes nowhere

window.postMessage() sends a message to another window — parent, opener, a child iframe’s contentWindow. Those references exist because the browser defines a hierarchy of windows and frames.

A native WebView host isn’t part of that hierarchy. The Android or iOS app embedding the WebView is a native process running a rendering engine, not a JS window object. So a page loaded inside a WebView calling window.parent.postMessage(token, "*") doesn’t error — it just has no listener on the other end. The message is sent into a hierarchy of one.

What actually bridges the gap

addJavascriptInterface(Object, name) — the simple, long-standing option on Android. The app exposes a Java/Kotlin object to the page as window.<name>, and the page calls it directly as a function: window.Android.onToken(token). It’s a real reflection call into native code, invoking whichever method the page names.

Methods must be annotated @JavascriptInterface (required since API 17, closing an older vulnerability where any public method was callable). Beyond that annotation, there’s no origin check — any page the WebView loads can call the bridge. That’s fine when the WebView only ever loads content you control. It’s a real risk the moment the WebView can navigate to a third-party or attacker-influenced URL, since it hands that page the ability to invoke arbitrary annotated native methods.

AndroidX WebMessageListener (WebViewCompat.postWebMessage / addWebMessageListener) — the newer replacement, and the one that actually implements standard postMessage/MessageChannel semantics between web content and native code. From the page’s side, it looks and behaves like real window.postMessage — because it is real postMessage, just delivered to a native listener instead of another window. The listener is registered with a set of allowed origins, so an unrelated page loaded into the same WebView can’t reach it. It depends on the installed WebView (Chromium) version on the device, so gate it behind WebViewFeature.isFeatureSupported(WEB_MESSAGE_LISTENER) before relying on it.

iOS has the same conceptual split: window.webkit.messageHandlers.<name>.postMessage(data) via WKScriptMessageHandler. It’s named postMessage, and reads like the web API, but it’s WKWebView’s own bridge — not cross-window messaging.

The rule of thumb

  • Parent context is a browser window or iframe → real window.postMessage / window.opener.postMessage works as expected.
  • Parent context is a native WebView host → real postMessage doesn’t cross the boundary. Use addJavascriptInterface for something quick on content you fully control, or the AndroidX WebMessageListener when the payload matters — auth tokens, session data, anything you wouldn’t want a stray page to be able to call into.

Same shape as the iframe case: figure out which container you’re actually in before picking the messaging mechanism, because the two look similar from the JS side and fail differently when you guess wrong.