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):
targetFrameexiststargetFrame.isMainFrameistrue
Iframe/subframe navigation (content loading in an iframe):
targetFrameexiststargetFrame.isMainFrameisfalse
Popup via window.open():
targetFrameisnil
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:
createWebViewWith→ “Should I create this popup?”decidePolicyFor→ “Should I load this URL in the popup I just created?”
But the second time they open that same named window:
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:
- User clicks “Login with Provider”
- JavaScript opens
https://auth.provider.comin a popup - User authenticates
- Provider redirects to
https://yourapp.com/callback?token=... - 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:
- We create the popup webview so it exists with the correct window name
- We use the passed
configurationwhich shares cookies/storage with the main webview — this is why authentication state automatically syncs - We block direct navigation to the auth provider in
decidePolicyFor - We start
ASWebAuthenticationSessionwith an HTTPS callback (iOS 17.4+) - The session listens for the auth provider to redirect to
https://yourapp.com/callback - When authentication completes, we load the callback URL into the popup
- 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.
Start the conversation