WebViews: a browser inside your app
Tap a link in Instagram and a page opens with no URL bar, no padlock, no address to check — that's not a browser, it's a WebView: a browser engine embedded in a native app, with the app holding all the power. What WebViews are, how the JavaScript-to-native bridge works, why OAuth providers ban them, and how they compare — feature by feature, threat by threat — with the iframe from Issue 007.
The issue
Tap a link inside Instagram and a web page slides up. It renders perfectly — it is the real page — but something's missing: there's no URL bar, no padlock, no way to check where you actually are. That's because you're not in a browser. You're in a WebView — a browser engine embedded inside a native app — and the difference between those two things is the entire subject of this issue. Issue 007 covered the iframe: a page inside a page, with the browser refereeing. The WebView is the mirror image — a page inside an app — and the referee doesn't exist.
An engine without a browser
A browser is really two products in one. Underneath is the engine — WebKit, or Chromium's Blink — which fetches, parses, and renders pages and runs their JavaScript. On top is the chrome: the URL bar, the padlock and certificate UI, safe-browsing warnings, the password manager, extensions, profiles, sync. A WebView is the engine shipped as a native UI component with none of the chrome. On iOS that's WKWebView, wrapping the system WebKit — and Apple requires every app, including Chrome for iOS historically, to use it. On Android it's the Android System WebView, a Chromium build that ships as its own package and updates through the Play Store. On the desktop, Electron and CEF bundle an entire Chromium inside each app, while WebView2 (Windows) and Tauri lean on a system-managed engine instead.
Where you meet them
WebViews are everywhere once you know to look. Hybrid apps — Cordova, Capacitor, Ionic — are a WebView stretched to full screen with your whole UI inside it, plus a bridge to native features; React Native apps embed them for individual screens. In-app browsers are the WebViews that social apps open links in rather than handing you to Safari or Chrome — keeping you (and your attention) inside the app. Checkout flows, help centers, and terms-of-service screens are very often web pages in disguise. WeChat's mini-programs run an entire app ecosystem inside WebViews. And every Electron app — VS Code, Slack, Figma's desktop client — is a bundled Chromium rendering web content with native powers bolted on.
The bridge: where WebViews get their power
An iframe and its parent talk through postMessage, and the conversation is capped by what a web page is allowed to do. A WebView's equivalent — the JavaScript bridge — has the same message-passing shape, but the other end is native code holding real device permissions. This is the feature that makes hybrid apps possible: the page calls a bridge method, native code opens the camera or reads a file, and the result comes back into JavaScript.
// Every @JavascriptInterface method becomes callable from ANY page
// this WebView ever loads. Treat it like a public, hostile API.
class Bridge(private val context: Context) {
@JavascriptInterface // required since API 17 — and that's the RCE fix
fun share(text: String) {
// validate first: any page in the WebView can call this
}
}
webView.settings.javaScriptEnabled = true
webView.addJavascriptInterface(Bridge(this), "AppBridge")
// From the page: AppBridge.share("hello from JS")
// The other direction — the app runs whatever script it likes:
webView.evaluateJavascript("document.title") { title ->
Log.d("wv", title)
}// The page posts messages; native receives them.
let config = WKWebViewConfiguration()
config.userContentController.add(self, name: "appBridge")
let webView = WKWebView(frame: .zero, configuration: config)
func userContentController(_ ucc: WKUserContentController,
didReceive message: WKScriptMessage) {
guard let body = message.body as? [String: Any] else { return }
// validate before acting — any loaded page can post here
}
// From the page:
// window.webkit.messageHandlers.appBridge.postMessage({ type: "share" })
// And, as on Android, the app can run script in any page it loads:
webView.evaluateJavaScript("document.querySelector('h1')?.textContent")Read those two snippets with Issue 007's postMessage rules in mind and the danger is obvious. There is no event.origin here — a bridge method can't reliably tell which page called it. If the WebView can navigate to third-party content while the bridge is attached, every site the user visits can call your native methods. The two working rules: expose a bridge only to your own content, and validate every message as if it arrived at a public server endpoint — because in effect, it did.
The trust model, inverted
Inside a WebView, the web's own rules still run: the same-origin policy referees between pages, exactly as in Issue 007. What's gone is any protection from the host. The app owns the engine, so the app can inject arbitrary JavaScript into any page (evaluateJavascript, WKUserScript), intercept and rewrite any request, read and set any cookie, and watch any input as it's typed. The same-origin policy has nothing to say about this — the host isn't another origin, it's the browser itself.
This is not hypothetical. In 2022, security researcher Felix Krause showed that the in-app browsers of Instagram, Facebook, and TikTok injected JavaScript into every external page users opened — Meta's apps added listeners that could observe taps and form input, and TikTok's script could monitor individual keystrokes. Whatever each company did with that data, the capability itself is the point: a page opened in an in-app browser has no defense against its host, and the user has no indicator that any of it is happening. It's also why security advice for users is boringly consistent — when an app offers "open in browser", take it, and never type a password into a page that has no URL bar.
The classic vulnerabilities
The host-versus-page problem is the deep one, but most real-world WebView exploits come from a shorter list of app-developer mistakes. The historical headliner is addJavascriptInterface before Android 4.2: the bridge exposed every public method of the injected object via reflection, so any page could chain its way to Runtime.exec and run native code — remote code execution from a web page (CVE-2012-6636). The @JavascriptInterface annotation exists because of that bug: only explicitly annotated methods cross the bridge now.
The rest are configuration flags doing exactly what they say. setAllowFileAccessFromFileURLs lets a file:// page read other local files — pair that with one injected script into a locally-rendered page and an attacker exfiltrates app-private data. Overriding onReceivedSslError with a blanket proceed() — a depressingly common "fix" for certificate errors during development — silently accepts man-in-the-middle certificates in production. Loading arbitrary URLs from intents or deep links into an internal, bridge-equipped WebView hands your native API to any site. And the engine itself can rot: iOS WebViews update with the OS and Android's through Play, but every Electron app ships its own frozen Chromium — an unmaintained Electron app is an unpatched browser with filesystem access.
Why OAuth banned the WebView
Here's the cleanest possible summary of the WebView trust problem: since 2016, Google simply refuses OAuth sign-ins from embedded WebViews — the dreaded disallowed_useragent error — and other identity providers followed. The reasoning is everything above, applied to a password field: OAuth's entire promise is that the user types their Google password into Google and the app never sees it. Inside the app's own WebView, that promise is void — the host can read the password as it's typed, autofill won't vouch for the origin, and the user can't check the URL. An embedded login page is indistinguishable from a phishing page by design.
The sanctioned replacements are a genuinely clever middle ground: Chrome Custom Tabs on Android and SFSafariViewController / ASWebAuthenticationSession on iOS render the real browser — its engine, its cookie jar (so existing logins work), its URL bar and padlock — inside a tab that visually belongs to your app but is completely outside your reach. No injection, no interception, no keystroke access. It's the best of both columns in Figure 1.
// Android — Chrome Custom Tabs: the user's real browser,
// presented inside your app, outside your reach.
CustomTabsIntent.Builder().build()
.launchUrl(context, Uri.parse("https://example.com/article"))
// iOS — SFSafariViewController: Safari's engine, cookies,
// URL bar and padlock, in an in-app sheet.
present(SFSafariViewController(url: url), animated: true)
// iOS, for login flows specifically — a browser session that
// exists only to complete an auth redirect:
let session = ASWebAuthenticationSession(
url: authURL, callbackURLScheme: "myapp") { callbackURL, error in
// exchange the code; the password never existed in your process
}So — better or worse than an iframe?
Wrong axis. The honest answer is that they solve mirrored problems with mirrored trust models. An iframe is the tool for mutually distrusting web parties: the browser stands above both pages, and Issue 007's whole toolkit — same-origin policy, sandbox, frame-ancestors, Permissions Policy — is the referee's rulebook. A WebView is the tool for one native party that wants web content with native powers: maximum capability, zero refereeing. Embedding hostile content in an iframe is a normal Tuesday; embedding hostile content in a bridge-equipped WebView is a vulnerability report.
Which yields a three-line decision rule. Your own content, and you want native integration: WebView, with the bridge treated as a public API. Anyone else's content: Custom Tab or SFSafariViewController, so the user gets the real browser's protections. A third party's login page: always the system flow — that one is not a preference, it's policy.
If you ship one: the hardening checklist
Put this issue next to Issue 007 and you have the whole picture of embedded web content. A page inside a page, with a referee: the iframe, where the interesting engineering is cooperating across a wall that protects both sides. A page inside an app, with no referee: the WebView, where the interesting engineering is not abusing — and not accidentally exposing — a position of total power. Same rectangle on the screen; opposite ends of the trust spectrum.