How to check frame navigation in WKWebView

Issue #1017

When your app loads web content with embedded iframes, deciding which navigations to allow becomes surprisingly tricky. A click in an ad banner behaves differently than a click on the main page content, and WKWebView’s delegate methods give you the tools to tell them apart.

In this article, we’ll explore how to use sourceFrame and targetFrame properties to detect where navigation events originate and where they intend to go. You’ll learn to distinguish main frame actions from iframe activity—essential knowledge for building secure, well-behaved web views.

Understanding frames in web content

Web pages aren’t always single documents. The <iframe> element lets developers embed external content—ads, videos, widgets, or even entire websites—inside a parent page. Each iframe creates its own browsing context with separate navigation history.

Here’s a typical webpage structure with iframes:

<!DOCTYPE html>
<html>
<head>
    <title>Main Page</title>
</head>
<body>
    <h1>Welcome to My App</h1>
    <p>This content lives in the main frame.</p>

    <!-- Embedded content in iframes -->
    <iframe id="ad-banner" src="https://ads.example.com/banner"></iframe>
    <iframe id="video-player" src="https://player.example.com/embed/12345"></iframe>

    <a href="/about">About Us</a>
</body>
</html>

When a user taps a link in this page, WKWebView needs to know: did they tap the “About Us” link in the main content, or did they tap something inside one of those iframes? The answer determines whether your app should navigate, open Safari, or block the request entirely.

The WKFrameInfo object

Before diving into navigation handling, let’s understand what WKWebView tells us about frames. The WKFrameInfo class provides metadata about any frame in your web content:

// WKFrameInfo key properties
frameInfo.isMainFrame      // Bool - true if this is the top-level document
frameInfo.request          // URLRequest - the request that loaded this frame
frameInfo.securityOrigin   // WKSecurityOrigin - origin info for security checks
frameInfo.webView          // WKWebView? - the web view containing this frame

The isMainFrame property is your primary tool for frame detection. When it returns true, you’re dealing with the top-level document. When false, the frame is an iframe embedded somewhere in the page hierarchy.

WKWebView provides frame information through two key delegate methods. Each serves a different purpose in the navigation lifecycle.

decidePolicyFor navigationAction

This method fires before any navigation begins, giving you the chance to allow or cancel it:

func webView(
    _ webView: WKWebView,
    decidePolicyFor navigationAction: WKNavigationAction,
    decisionHandler: @escaping (WKNavigationActionPolicy) -> Void
) {
    let sourceFrame = navigationAction.sourceFrame
    let targetFrame = navigationAction.targetFrame

    print("Source is main frame: \(sourceFrame.isMainFrame)")
    print("Target is main frame: \(targetFrame?.isMainFrame ?? false)")

    decisionHandler(.allow)
}

That does a few important things:

  1. sourceFrame tells you which frame initiated the navigation—where the user clicked or where JavaScript called window.location
  2. targetFrame tells you where the content should load, which might be nil for new window requests
  3. The decision handler lets you allow, cancel, or download the request

createWebViewWith configuration

When web content wants to open a new window (via target="_blank" or JavaScript’s window.open), this WKUIDelegate method fires:

func webView(
    _ webView: WKWebView,
    createWebViewWith configuration: WKWebViewConfiguration,
    for navigationAction: WKNavigationAction,
    windowFeatures: WKWindowFeatures
) -> WKWebView? {
    // Check if this should open in the current view
    if let frame = navigationAction.targetFrame, frame.isMainFrame {
        return nil  // Let normal navigation proceed
    }

    // Handle the new window request in your current web view
    if let url = navigationAction.request.url {
        webView.load(URLRequest(url: url))
    }

    return nil  // Don't create a new WKWebView
}

Important: You must set webView.uiDelegate = self for this method to be called. Without it, target="_blank" links will silently fail.

sourceFrame vs targetFrame: when to use each

Understanding the distinction between these properties is crucial for correct navigation handling.

sourceFrame: where did the action originate?

The sourceFrame property answers “which frame triggered this navigation?” Use it when you need to know the context of user interaction:

func webView(
    _ webView: WKWebView,
    decidePolicyFor navigationAction: WKNavigationAction,
    decisionHandler: @escaping (WKNavigationActionPolicy) -> Void
) {
    let source = navigationAction.sourceFrame

    if source.isMainFrame {
        // User clicked something in the main page content
        print("Navigation initiated from main document")
    } else {
        // Click happened inside an iframe
        print("Navigation initiated from embedded iframe")
        print("Iframe origin: \(source.securityOrigin.host)")
    }

    decisionHandler(.allow)
}

Common scenarios where sourceFrame matters:

  • Ad blocking: Reject navigations that originate from known ad iframe domains
  • Analytics: Track whether users engage with main content or embedded widgets
  • Security: Apply stricter policies to navigation requests from third-party iframes

targetFrame: where should content load?

The targetFrame property answers “where will this content appear?” It can be nil when content wants to open in a new window:

func webView(
    _ webView: WKWebView,
    decidePolicyFor navigationAction: WKNavigationAction,
    decisionHandler: @escaping (WKNavigationActionPolicy) -> Void
) {
    if let target = navigationAction.targetFrame {
        if target.isMainFrame {
            // Content will replace the entire page
            print("Full page navigation")
        } else {
            // Content will load inside an iframe
            print("Iframe content loading")
        }
    } else {
        // No target frame means new window/tab request
        print("New window requested")
    }

    decisionHandler(.allow)
}

When targetFrame is nil, the web content is trying to open a new browsing context. You’ll typically handle this in createWebViewWith instead.

Practical use cases

Let’s look at real-world scenarios where frame checking becomes essential.

Blocking iframe navigation attempts

Prevent iframes from hijacking your web view’s main content:

func webView(
    _ webView: WKWebView,
    decidePolicyFor navigationAction: WKNavigationAction,
    decisionHandler: @escaping (WKNavigationActionPolicy) -> Void
) {
    let source = navigationAction.sourceFrame
    let target = navigationAction.targetFrame

    // Block if an iframe tries to navigate the main frame
    if !source.isMainFrame && (target?.isMainFrame ?? false) {
        print("Blocked: iframe attempted to navigate main frame")
        decisionHandler(.cancel)
        return
    }

    decisionHandler(.allow)
}

This pattern protects against clickjacking attacks where malicious iframes try to redirect users away from your intended content.

Sometimes you want iframe links to open in Safari rather than your embedded web view:

func webView(
    _ webView: WKWebView,
    decidePolicyFor navigationAction: WKNavigationAction,
    decisionHandler: @escaping (WKNavigationActionPolicy) -> Void
) {
    guard navigationAction.navigationType == .linkActivated else {
        decisionHandler(.allow)
        return
    }

    let source = navigationAction.sourceFrame

    // Links clicked in iframes open externally
    if !source.isMainFrame, let url = navigationAction.request.url {
        UIApplication.shared.open(url)
        decisionHandler(.cancel)
        return
    }

    decisionHandler(.allow)
}

Intercepting iframe loads for content filtering

Monitor what iframes are loading to enforce content policies:

func webView(
    _ webView: WKWebView,
    decidePolicyFor navigationAction: WKNavigationAction,
    decisionHandler: @escaping (WKNavigationActionPolicy) -> Void
) {
    let target = navigationAction.targetFrame

    // Only check iframe loads, not main frame
    if let target = target, !target.isMainFrame {
        guard let url = navigationAction.request.url,
              let host = url.host else {
            decisionHandler(.allow)
            return
        }

        let blockedDomains = ["ads.example.com", "tracker.example.com"]

        if blockedDomains.contains(where: { host.contains($0) }) {
            print("Blocked iframe load: \(host)")
            decisionHandler(.cancel)
            return
        }
    }

    decisionHandler(.allow)
}

Complete delegate implementation

Here’s a production-ready implementation combining these patterns:

final class WebViewController: UIViewController {
    private let webView: WKWebView

    init() {
        let configuration = WKWebViewConfiguration()
        self.webView = WKWebView(frame: .zero, configuration: configuration)
        super.init(nibName: nil, bundle: nil)
    }

    required init?(coder: NSCoder) {
        fatalError("init(coder:) has not been implemented")
    }

    override func viewDidLoad() {
        super.viewDidLoad()

        view.addSubview(webView)
        webView.frame = view.bounds
        webView.autoresizingMask = [.flexibleWidth, .flexibleHeight]

        webView.navigationDelegate = self
        webView.uiDelegate = self  // Required for createWebViewWith
    }
}

extension WebViewController: WKNavigationDelegate {
    func webView(
        _ webView: WKWebView,
        decidePolicyFor navigationAction: WKNavigationAction,
        decisionHandler: @escaping (WKNavigationActionPolicy) -> Void
    ) {
        let source = navigationAction.sourceFrame
        let target = navigationAction.targetFrame

        // Log frame information for debugging
        logFrameInfo(source: source, target: target)

        // Security: block iframe-to-main-frame navigation hijacking
        if !source.isMainFrame && (target?.isMainFrame ?? false) {
            decisionHandler(.cancel)
            return
        }

        decisionHandler(.allow)
    }

    private func logFrameInfo(source: WKFrameInfo, target: WKFrameInfo?) {
        print("""
        Navigation:
          Source: \(source.isMainFrame ? "main" : "iframe") (\(source.securityOrigin.host))
          Target: \(target?.isMainFrame ?? false ? "main" : "iframe/new window")
        """)
    }
}

extension WebViewController: WKUIDelegate {
    func webView(
        _ webView: WKWebView,
        createWebViewWith configuration: WKWebViewConfiguration,
        for navigationAction: WKNavigationAction,
        windowFeatures: WKWindowFeatures
    ) -> WKWebView? {
        // Handle target="_blank" and window.open
        if let frame = navigationAction.targetFrame, frame.isMainFrame {
            return nil
        }

        // Load in current web view instead of opening new window
        if let url = navigationAction.request.url {
            webView.load(URLRequest(url: url))
        }

        return nil
    }
}

Web code examples: testing iframe scenarios

To test your frame handling logic, create an HTML page with various iframe configurations:

<!DOCTYPE html>
<html>
<head>
    <meta name="viewport" content="width=device-width, initial-scale=1">
    <title>Frame Testing Page</title>
    <style>
        body { font-family: -apple-system, sans-serif; padding: 20px; }
        iframe { border: 2px solid #007AFF; margin: 10px 0; }
        .section { margin: 20px 0; padding: 15px; background: #f5f5f7; border-radius: 8px; }
        h2 { color: #1d1d1f; }
    </style>
</head>
<body>
    <h1>WKWebView Frame Testing</h1>

    <div class="section">
        <h2>Main Frame Links</h2>
        <p>These links are in the main frame:</p>
        <a href="https://apple.com">Apple (same window)</a><br>
        <a href="https://developer.apple.com" target="_blank">Developer (new window)</a>
    </div>

    <div class="section">
        <h2>Iframe with Links</h2>
        <iframe id="link-frame" srcdoc="
            <html>
            <body style='font-family: sans-serif; padding: 10px;'>
                <p>Links inside iframe:</p>
                <a href='https://webkit.org'>WebKit (iframe navigation)</a><br>
                <a href='https://swift.org' target='_top'>Swift (targets main frame)</a><br>
                <a href='https://github.com' target='_blank'>GitHub (new window from iframe)</a>
            </body>
            </html>
        " width="100%" height="150"></iframe>
    </div>

    <div class="section">
        <h2>JavaScript Navigation Tests</h2>
        <button onclick="testMainFrameJS()">Navigate Main Frame (JS)</button>
        <button onclick="testIframeJS()">Navigate Iframe (JS)</button>

        <iframe id="js-frame" src="about:blank" width="100%" height="100"></iframe>
    </div>

    <script>
        function testMainFrameJS() {
            // This navigates the main frame via JavaScript
            window.location.href = 'https://example.com';
        }

        function testIframeJS() {
            // This navigates only the iframe
            const iframe = document.getElementById('js-frame');
            iframe.src = 'https://example.org';
        }

        // Test iframe trying to navigate parent (security concern)
        window.addEventListener('message', function(event) {
            console.log('Message from iframe:', event.data);
        });
    </script>
</body>
</html>

Testing different target attributes

The HTML target attribute affects how targetFrame and isMainFrame behave:

Target Attribute targetFrame isMainFrame Behavior
(none) Current frame Depends on context Navigates the frame containing the link
_self Current frame Depends on context Same as no target
_parent Parent frame true if parent is main Navigates the parent frame
_top Main frame true Always navigates the top-level document
_blank nil N/A Opens new window (triggers createWebViewWith)

JavaScript that tests frame relationships

Add this script to your test page to explore frame hierarchy programmatically:

// Check if current context is the top window
function isMainFrame() {
    return window === window.top;
}

// Get information about the current frame context
function getFrameInfo() {
    return {
        isMain: window === window.top,
        isFramed: window !== window.top,
        parentOrigin: window.parent !== window ? getParentOrigin() : null,
        depth: getFrameDepth()
    };
}

function getParentOrigin() {
    try {
        return window.parent.location.origin;
    } catch (e) {
        return 'cross-origin (blocked)';
    }
}

function getFrameDepth() {
    let depth = 0;
    let current = window;
    while (current !== current.top) {
        depth++;
        current = current.parent;
    }
    return depth;
}

// Log frame info on page load
console.log('Frame Info:', JSON.stringify(getFrameInfo(), null, 2));

Frame detection in WKWebView comes down to understanding two properties:

  • sourceFrame.isMainFrame: Did the action originate in the main document or an embedded iframe?
  • targetFrame?.isMainFrame: Will the resulting content load in the main document, an iframe, or a new window?

By combining these checks, you can build sophisticated navigation policies that protect users from malicious content while preserving legitimate iframe functionality. Whether you’re blocking ad-based redirects, enforcing content security policies, or simply handling target="_blank" links gracefully, frame information gives you the context you need to make informed decisions

Written by

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

Start the conversation