How to handle popup with WKWebView

Issue #1011

You’re building an iOS app with embedded web content. Everything works perfectly until your users need to authenticate through a third-party provider — the popup gets blocked, or the callback never makes it back.

WKWebView’s popup handling follows predictable patterns once you understand how window.open() behaves, when delegate methods fire, and how to wire up authentication flows.

window.open() Named vs Unnamed Windows

Before diving into iOS code, let’s understand what’s happening on the web side. When JavaScript calls window.open(), it has two very different behaviors depending on the second parameter.

Here’s what most developers expect:

// Opens a new popup every time
window.open('https://example.com', '_blank', 'width=800,height=600');

Each time this runs, you get a fresh popup window. That’s the _blank behavior — always create new.

But here’s where it gets interesting:

// First call: creates a new popup
window.open('https://auth.provider.com', 'authWindow', 'width=800,height=600');

// Second call: reuses the existing popup!
window.open('https://other-site.com', 'authWindow', 'width=800,height=600');

That second call doesn’t create a new popup. Instead, it finds the existing window named 'authWindow' and navigates it to the new URL. This behavior is crucial for understanding why your iOS delegate methods fire differently on subsequent calls.

Let me show you a practical test page that demonstrates this:

<!DOCTYPE html>
<html>
<head>
    <title>Popup Behavior Test</title>
</head>
<body>
    <h1>WKWebView Popup Test</h1>

    <button onclick="testNamedPopup()">
        Open Named Popup
    </button>

    <button onclick="testUnnamedPopup()">
        Open Unnamed Popup
    </button>

    <script>
        function testNamedPopup() {
            // Click once: creates window
            // Click again: navigates existing window
            window.open(
                'https://example.com',
                'testPopup',
                'width=800,height=600'
            );
        }

        function testUnnamedPopup() {
            // Every click creates a new window
            window.open(
                'https://example.com',
                '_blank',
                'width=800,height=600'
            );
        }
    </script>
</body>
</html>

Load this in your WKWebView and click each button twice. You’ll notice the named popup reuses the same window, while the unnamed popup creates a new one each time.

Understanding Target Frames

Before diving into delegates, you need to understand how WKWebView distinguishes between different types of navigation through the targetFrame property in WKNavigationAction.

Main frame navigation (regular links, form submissions):

  • targetFrame exists
  • targetFrame.isMainFrame is true

Iframe/subframe navigation (content loading in an iframe):

  • targetFrame exists
  • targetFrame.isMainFrame is false

Popup via window.open():

  • targetFrame is nil

This distinction is crucial because createWebViewWith is only called when targetFrame is nil — that is, when JavaScript is trying to create a new window. If targetFrame exists (whether main frame or iframe), it’s just regular navigation within an existing frame.

Here’s a quick test to see this in action:

<!DOCTYPE html>
<html>
<body>
    <h1>Frame Test</h1>

    <!-- Main frame navigation -->
    <a href="https://example.com">Regular Link (main frame)</a>

    <!-- Iframe -->
    <iframe src="https://example.com" width="300" height="200"></iframe>

    <!-- Popup -->
    <button onclick="window.open('https://example.com')">
        Open Popup (targetFrame = nil)
    </button>
</body>
</html>

When you click the regular link or the iframe loads, targetFrame exists. Only the popup button creates a nil targetFrame, triggering createWebViewWith.

WKUIDelegate and WKNavigationDelegate

Now let’s look at how iOS handles these popup requests. WKWebView uses two separate delegate protocols, and understanding when each fires is key to implementing popups correctly.

Let’s start by setting up a basic WKWebView with both delegates:

class WebViewController: UIViewController {
    let webView = WKWebView()
    weak var popupWebView: WKWebView?

    override func viewDidLoad() {
        super.viewDidLoad()

        webView.uiDelegate = self
        webView.navigationDelegate = self

        view.addSubview(webView)
        // ... constraints ...
    }
}

createWebViewWith - Creating Popups

When JavaScript calls window.open(), WKWebView first asks your UI delegate whether to create a new web view:

extension WebViewController: WKUIDelegate {
    func webView(
        _ webView: WKWebView,
        createWebViewWith configuration: WKWebViewConfiguration,
        for navigationAction: WKNavigationAction,
        windowFeatures: WKWindowFeatures
    ) -> WKWebView? {
        // Only called when targetFrame is nil (popup)
        // Not called for main frame or iframe navigation

        // Return a WKWebView to allow the popup
        // Return nil to block it
    }
}

Notice the configuration parameter — this is crucial. WKWebView passes you a configuration that shares the same data store (cookies, local storage, session storage) as the parent webview. This means if you create your popup with this configuration:

let popupWebView = WKWebView(frame: .zero, configuration: configuration)

The popup will automatically share cookies and storage with the main webview. This is exactly what you want for authentication flows — when the popup receives an auth token or session cookie, the main webview can immediately access it too.

This method is called when a webview (that has a UIDelegate) tries to create a new popup via window.open():

  • First time opening a named window
  • Every time opening an unnamed window ('_blank')

This method is NOT called when:

  • The same named window is opened again (it navigates the existing popup instead)
  • Regular navigation happens (clicking normal links, form submissions)
  • The webview doesn’t have a UIDelegate set

Important: If you set delegates on both your main webview and popup webview, createWebViewWith can be called from either one. When your popup webview calls window.open(), the popup’s delegate receives the createWebViewWith call, not the main webview’s delegate.

decidePolicyFor - Controlling Navigation

Right after createWebViewWith returns a web view, WKWebView calls your navigation delegate to ask if the URL should load:

extension WebViewController: WKNavigationDelegate {
    func webView(
        _ webView: WKWebView,
        decidePolicyFor navigationAction: WKNavigationAction,
        decisionHandler: @escaping (WKNavigationActionPolicy) -> Void
    ) {
        // Called for all navigation types:
        // - Main frame (targetFrame.isMainFrame == true)
        // - Iframe (targetFrame exists, isMainFrame == false)
        // - Popup initial load (after createWebViewWith returns a webview)

        // Allow or cancel the navigation
        decisionHandler(.allow)
    }
}

This method is called for every navigation attempt in any webview that has a WKNavigationDelegate:

  • Regular link clicks and form submissions
  • JavaScript navigations
  • Back/forward navigation
  • Navigating existing popups to new URLs

If you set a delegate on your popup webview (like we did in createWebViewWith), then decidePolicyFor will also be called for all navigation that happens in that popup. This is why you need to check the webView parameter — to know whether the main webview or popup webview is navigating.

Here’s the crucial part: both methods fire when creating a new popup. The first time a user opens a named window, you’ll see:

  1. createWebViewWith → “Should I create this popup?”
  2. decidePolicyFor → “Should I load this URL in the popup I just created?”

But the second time they open that same named window:

  1. decidePolicyFor → “Should I navigate the existing popup to this new URL?”

One mistake that’s sometimes made is assuming only one delegate method handles popups. In reality, they work together — the UI delegate creates the container, and the navigation delegate controls what loads inside it.

Sharing Delegates Between WebViews

Here’s a common pattern that can be confusing: setting the same delegate object for both your main webview and popup webview.

func webView(
    _ webView: WKWebView,
    createWebViewWith configuration: WKWebViewConfiguration,
    for navigationAction: WKNavigationAction,
    windowFeatures: WKWindowFeatures
) -> WKWebView? {
    let popupWebView = WKWebView(
        frame: webView.frame,
        configuration: configuration
    )

    // Same delegates as main webview
    popupWebView.navigationDelegate = self
    popupWebView.uiDelegate = self

    view.addSubview(popupWebView)
    self.popupWebView = popupWebView

    return popupWebView
}

This works, but there’s a catch: both webviews now call your same delegate methods. When decidePolicyFor fires, you need to know which webview triggered it:

func webView(
    _ webView: WKWebView,
    decidePolicyFor navigationAction: WKNavigationAction,
    decisionHandler: @escaping (WKNavigationActionPolicy) -> Void
) {
    guard let url = navigationAction.request.url else {
        decisionHandler(.cancel)
        return
    }

    // Check which webview this is
    if webView === self.webView {
        // Main webview navigation
        print("Main webview navigating to:", url)
    } else if webView === self.popupWebView {
        // Popup webview navigation
        print("Popup webview navigating to:", url)
    }

    decisionHandler(.allow)
}

The same applies to all navigation delegate methods — didFinish, didFail, didCommit — they all receive calls from both webviews. Always check the webView parameter to know which one is calling.

Authentication Flows

Alright, just one piece of the puzzle left: handling authentication that requires opening external providers. This is where the named window behavior becomes critical.

Let’s walk through a typical OAuth flow:

  1. User clicks “Login with Provider”
  2. JavaScript opens https://auth.provider.com in a popup
  3. User authenticates
  4. Provider redirects to https://yourapp.com/callback?token=...
  5. Your app needs to capture that callback URL

For better security on iOS 12+, you should use ASWebAuthenticationSession instead of directly loading the auth provider in your webview:

func webView(
    _ webView: WKWebView,
    createWebViewWith configuration: WKWebViewConfiguration,
    for navigationAction: WKNavigationAction,
    windowFeatures: WKWindowFeatures
) -> WKWebView? {
    guard let url = navigationAction.request.url else {
        return nil
    }

    // Check if this is an authentication flow
    if url.host == "auth.provider.com" {
        let popup = WKWebView(
            frame: webView.frame,
            configuration: configuration
        )
        popup.navigationDelegate = self
        popup.uiDelegate = self
        view.addSubview(popup)
        self.popupWebView = popup

        return popup
    }

    return nil
}

Now handle the navigation to block the auth provider and start the authentication session instead:

func webView(
    _ webView: WKWebView,
    decidePolicyFor navigationAction: WKNavigationAction,
    decisionHandler: @escaping (WKNavigationActionPolicy) -> Void
) {
    guard let url = navigationAction.request.url else {
        decisionHandler(.cancel)
        return
    }

    // Block direct navigation to auth provider
    if url.host == "auth.provider.com" {
        decisionHandler(.cancel)
        startAuthenticationSession(url: url)
        return
    }

    decisionHandler(.allow)
}

func startAuthenticationSession(url: URL) {
    guard #available(iOS 17.4, *) else {
        // Fallback for older iOS versions
        return
    }

    let session = ASWebAuthenticationSession(
        url: url,
        callback: .https(host: "yourapp.com", path: "/callback")
    ) { [weak self] callbackURL, error in
        guard let self = self,
              let callbackURL = callbackURL else {
            return
        }

        // Load the callback in the popup webview
        let request = URLRequest(url: callbackURL)
        self.popupWebView?.load(request)
    }

    session.presentationContextProvider = self
    session.prefersEphemeralWebBrowserSession = true
    session.start()
}

That does a few important things:

  1. We create the popup webview so it exists with the correct window name
  2. We use the passed configuration which shares cookies/storage with the main webview — this is why authentication state automatically syncs
  3. We block direct navigation to the auth provider in decidePolicyFor
  4. We start ASWebAuthenticationSession with an HTTPS callback (iOS 17.4+)
  5. The session listens for the auth provider to redirect to https://yourapp.com/callback
  6. When authentication completes, we load the callback URL into the popup
  7. The callback page sets cookies or calls APIs, and because the popup shares the data store with the main webview, the main webview immediately has access to the authenticated state

Note: Using HTTPS callback (.https(host:path:)) is preferred over custom URL schemes because it doesn’t require registering a custom scheme in your Info.plist and provides better security. For iOS versions before 17.4, you can still use the older callbackURLScheme parameter with a custom scheme like "myapp".

The callback URL can then communicate with the parent page using window.opener:

// In the callback page
if (window.opener) {
    const params = new URLSearchParams(window.location.search);
    const token = params.get('token');

    // Send data back to parent window
    window.opener.postMessage({
        source: 'auth-callback',
        token: token
    }, window.location.origin);

    // Close the popup
    window.close();
}

And the main page listens for that message:

// In the main page
window.addEventListener('message', function(event) {
    // Always verify the origin for security
    if (event.origin !== window.location.origin) {
        return;
    }

    if (event.data.source === 'auth-callback') {
        console.log('Authentication successful:', event.data.token);
        // Update your UI with the auth token
    }
});

Web content in iOS apps will always have some rough edges, but understanding how WKWebView’s popup system works makes those edges much smoother. The delegate methods might seem confusing at first, but they’re following a consistent pattern: create the container, then control what goes in it.

Written by

I’m open source contributor, writer, speaker and product maker.

Start the conversation