WKWebView is one of those APIs that looks harmless until you ship a hybrid app, load a little untrusted HTML, and accidentally hand JavaScript access to native code.

That’s where XSS in iOS apps gets ugly.

Browser XSS is already bad. XSS inside WKWebView can be worse because the payload may not stop at stealing cookies or rewriting the DOM. If your app exposes native functionality through WKScriptMessageHandler, custom URL schemes, or sloppy navigation delegates, injected JavaScript can start poking at app internals.

I’ve seen teams treat WKWebView like a sandbox. It isn’t. It’s a browser engine embedded in your app, and if you mix trusted native capabilities with untrusted web content, you’ve built a privilege bridge attackers will absolutely use.

Why XSS in WKWebView is different

Normal web XSS usually lives inside the browser security model. In a WKWebView, you often add extra powers:

  • JavaScript-to-native message handlers
  • Access to app-specific tokens or state
  • Local HTML bundled with the app
  • Relaxed navigation rules
  • Custom URL schemes
  • Injected scripts at document start

So the real question isn’t just “can someone inject JavaScript?” It’s “what can that JavaScript reach once it runs?”

A vulnerable WKWebView often looks like this:

import WebKit
import UIKit

class ViewController: UIViewController, WKScriptMessageHandler {
    var webView: WKWebView!

    override func viewDidLoad() {
        super.viewDidLoad()

        let config = WKWebViewConfiguration()
        let contentController = WKUserContentController()

        contentController.add(self, name: "nativeBridge")
        config.userContentController = contentController

        webView = WKWebView(frame: view.bounds, configuration: config)
        view.addSubview(webView)

        let userBio = """
        <h1>Profile</h1>
        <div>\(loadBioFromServer())</div>
        """

        webView.loadHTMLString(userBio, baseURL: nil)
    }

    func userContentController(_ userContentController: WKUserContentController,
                               didReceive message: WKScriptMessage) {
        if message.name == "nativeBridge" {
            print("Received: \(message.body)")
        }
    }

    func loadBioFromServer() -> String {
        return "<img src=x onerror=\"window.webkit.messageHandlers.nativeBridge.postMessage('owned')\">"
    }
}

That’s XSS with a native bridge attached.

The injected payload runs because raw HTML is inserted into loadHTMLString, and once it runs, it can call:

window.webkit.messageHandlers.nativeBridge.postMessage("owned")
```text

If that handler triggers file access, token retrieval, analytics events, deep links, or privileged actions, you’ve crossed from “web bug” into “app compromise.”

## Common WKWebView XSS patterns

### 1. Loading unsanitized HTML with `loadHTMLString`

This is the classic mistake. Teams take CMS content, markdown output, chat messages, product descriptions, or support content and render it directly.

Bad:

let html = “(untrustedContent)” webView.loadHTMLString(html, baseURL: nil)


If `untrustedContent` contains scriptable markup, event handlers, or dangerous URLs, you’re done.

Safer approach: render escaped text unless you truly need HTML.

func escapeHTML(_ input: String) -> String { var result = input result = result.replacingOccurrences(of: “&”, with: “&”) result = result.replacingOccurrences(of: “<”, with: “<”) result = result.replacingOccurrences(of: “>”, with: “>”) result = result.replacingOccurrences(of: “"”, with: “"”) result = result.replacingOccurrences(of: “’”, with: “'”) return result }

let safeContent = escapeHTML(untrustedContent) let html = “

(safeContent)
” webView.loadHTMLString(html, baseURL: nil)


If you actually need rich HTML, sanitize it before it ever reaches the app. Don’t try to regex your way out of this.

## 2. Dangerous JavaScript bridges

This is where many real-world mobile exploits live.

A common pattern:

contentController.add(self, name: “app”)


And then:

func userContentController(_ userContentController: WKUserContentController, didReceive message: WKScriptMessage) { guard message.name == “app” else { return }

if let dict = message.body as? [String: Any],
   let action = dict["action"] as? String {

    if action == "openCamera" {
        openCamera()
    } else if action == "getToken" {
        sendTokenToPage()
    }
}

}


If an attacker gets XSS in that page, they can call your bridge directly:

```javascript
window.webkit.messageHandlers.app.postMessage({
  action: "getToken"
});

Fixes for script bridges

Treat every message as untrusted input, even if it comes from “your page.”

Use strict validation:

func userContentController(_ userContentController: WKUserContentController,
                           didReceive message: WKScriptMessage) {
    guard message.name == "app" else { return }
    guard let dict = message.body as? [String: Any] else { return }
    guard let action = dict["action"] as? String else { return }

    switch action {
    case "openHelp":
        openHelpScreen()
    default:
        return
    }
}

Better yet:

  • Expose the smallest possible bridge surface
  • Never expose secrets to JavaScript
  • Don’t offer privileged actions without additional native-side checks
  • Split trusted and untrusted content into separate web views if needed

If a page displays user-generated content, I would not attach a powerful bridge to it at all.

3. evaluateJavaScript with untrusted data

Another easy footgun:

let username = getUsernameFromAPI()
webView.evaluateJavaScript("showUser('\(username)')")

If username contains quotes and payloads, you’ve created script injection.

Bad input:

'); window.webkit.messageHandlers.app.postMessage({action:'owned'}); //

Safer pattern: serialize data as JSON.

func jsStringLiteral(from value: String) -> String? {
    guard let data = try? JSONSerialization.data(withJSONObject: [value], options: []),
          let json = String(data: data, encoding: .utf8) else {
        return nil
    }
    return json.dropFirst().dropLast().description
}

if let safeArg = jsStringLiteral(from: username) {
    webView.evaluateJavaScript("showUser(\(safeArg))")
}

Even better, avoid building code strings at all when you can redesign the interaction.

4. Allowing hostile navigation

Developers often let WKWebView roam freely:

func webView(_ webView: WKWebView,
             decidePolicyFor navigationAction: WKNavigationAction,
             decisionHandler: @escaping (WKNavigationActionPolicy) -> Void) {
    decisionHandler(.allow)
}

That means injected content may navigate to attacker-controlled pages that still benefit from your bridge or app context.

Lock it down:

let allowedHosts: Set<String> = ["example.com", "static.example.com"]

func webView(_ webView: WKWebView,
             decidePolicyFor navigationAction: WKNavigationAction,
             decisionHandler: @escaping (WKNavigationActionPolicy) -> Void) {

    guard let url = navigationAction.request.url,
          let host = url.host,
          allowedHosts.contains(host),
          url.scheme == "https" else {
        decisionHandler(.cancel)
        return
    }

    decisionHandler(.allow)
}

I’d also block unexpected schemes unless there’s a very specific reason to allow them.

5. Local file content and bundled HTML

Apps often load local files:

let url = Bundle.main.url(forResource: "index", withExtension: "html")!
webView.loadFileURL(url, allowingReadAccessTo: url.deletingLastPathComponent())

That by itself isn’t the problem. The problem is local HTML that later injects remote data unsafely, or local content with privileged JavaScript hooks.

Developers trust bundled files too much. Attackers don’t need to modify the bundle if they can influence data rendered inside it.

Harden with Content Security Policy

CSP is not a silver bullet in WKWebView, but it’s still one of the best ways to reduce XSS impact if you control the HTML.

A basic CSP for web content loaded in the app might look like:

<meta http-equiv="Content-Security-Policy"
      content="default-src 'self'; script-src 'self'; object-src 'none'; base-uri 'none'; frame-ancestors 'none'">

That blocks inline scripts unless you explicitly allow them. Good. Inline handlers like onerror= should stop working under a proper policy.

If you need a deeper CSP rollout guide, csp-guide.com is a solid reference.

For remote content, send CSP as an HTTP response header rather than relying on a meta tag. You can verify your headers with tools like HeaderTest, which is handy when you’re checking whether your app backend is actually returning the policy you thought you deployed.

Safer WKWebView setup

Here’s a more defensive baseline:

import UIKit
import WebKit

class SafeWebViewController: UIViewController, WKNavigationDelegate {
    private var webView: WKWebView!
    private let allowedHosts: Set<String> = ["example.com"]

    override func viewDidLoad() {
        super.viewDidLoad()

        let config = WKWebViewConfiguration()
        let contentController = WKUserContentController()

        // No bridge attached for untrusted content
        config.userContentController = contentController

        let prefs = WKPreferences()
        prefs.javaScriptCanOpenWindowsAutomatically = false
        config.preferences = prefs

        webView = WKWebView(frame: view.bounds, configuration: config)
        webView.navigationDelegate = self
        view.addSubview(webView)

        if let url = URL(string: "https://example.com/help") {
            webView.load(URLRequest(url: url))
        }
    }

    func webView(_ webView: WKWebView,
                 decidePolicyFor navigationAction: WKNavigationAction,
                 decisionHandler: @escaping (WKNavigationActionPolicy) -> Void) {

        guard let url = navigationAction.request.url,
              let host = url.host,
              url.scheme == "https",
              allowedHosts.contains(host) else {
            decisionHandler(.cancel)
            return
        }

        decisionHandler(.allow)
    }
}

This doesn’t magically fix XSS, but it removes a lot of the “XSS becomes native abuse” paths.

Practical rules I’d enforce on every team

  1. Never render untrusted HTML without sanitization
  2. Escape untrusted text by default
  3. Do not attach native bridges to untrusted pages
  4. Assume every bridge message is attacker-controlled
  5. Avoid evaluateJavaScript string interpolation
  6. Restrict navigation to approved HTTPS origins
  7. Use CSP on any HTML you control
  8. Separate trusted app UI from user-generated web content
  9. Don’t expose tokens, secrets, or privileged actions to JavaScript
  10. Test payloads like a browser attacker, not just an iOS developer

A simple test payload

If you suspect unsafe rendering, try injecting:

<img src=x onerror="alert('xss')">

If there’s a bridge, try:

<img src=x onerror="window.webkit.messageHandlers.app.postMessage({action:'ping'})">

If that works, don’t downplay it as “just web content.” In a mobile app, that bridge is the whole game.

WKWebView is fine when you treat it like a hostile boundary. Most of the serious bugs happen when developers treat it like a trusted widget. That assumption is what attackers cash in on.