Hosted Flows for iOS

This section shows how to embed a Socure Hosted Flow in an iOS app using WebView with a minimal, in-app integration.

What is a WebView?

A WebView allows a mobile app to display web content inside the app—without redirecting the user to Safari or another browser. On iOS, this functionality comes from WKWebView, a modern and secure web rendering engine.

Using a WebView lets your app load and interact with Socure’s Hosted Flow as if it were a native screen, while still benefiting from all the power and flexibility of a web-based identity verification experience.


Why use a WebView for Hosted Flows?

Seamless Integration

Embed Socure Hosted Flows without building native user forms and document capture UI.

Keep Users In-App

Keep users in-app while leveraging a fully hosted, compliant, and frequently updated web experience.

Single Code Path

A single code path across platforms: reuse your web-based identity flow logic and configurations.

Rapid Iteration

Rapid iteration on copy, styling, or flow variants without redeploying the app.


Before you start

Make sure you have the following:

Xcode 14+ and iOS 14+ (iOS 15+ recommended for the async media-permission API used below)

A Socure Hosted Flow URL (Sandbox or Production), for example:


Required app capabilities

  1. Camera permissions:

    • Add the following key to your app’s Info.plist, along with a clear reason for requesting access:
      • NSCameraUsageDescription — required for capturing document images and selfies.
    • iOS will display this message the moment the Hosted Flow requests camera access in the WebView.
  2. HTTPS:

    • Socure RiskOS™ endpoints use HTTPS.
    • No App Transport Security (ATS) exceptions or domain whitelisting are required for Sandbox or Production.
  3. User gesture for media:

    • iOS will not show a camera prompt automatically on page load.
    • A user must tap something first (e.g., “Verify Identity”).
    • Avoid loading the Hosted Flow immediately on app launch without interaction.
👍

Tip:

Test on a physical device. iOS simulators cannot access the camera.


Minimal WebView configuration

At a minimum, your WebView setup should:

  1. Use WKWebView with mobile-friendly settings.
  2. Allow inline media playback for camera and video streams.
  3. Assign navigation & UI delegates.
  4. Load the Socure Hosted Flow URL.
  5. (Recommended) Handle external links in Safari.
  6. (For iOS 15+) Grant media capture permissions programmatically

A minimal configuration looks like the following example:

let webConfiguration = WKWebViewConfiguration()
let pref = WKWebpagePreferences()
pref.preferredContentMode = .mobile
webConfiguration.defaultWebpagePreferences = pref
webConfiguration.allowsInlineMediaPlayback = true

if #available(iOS 10.0, *) {
    webConfiguration.mediaTypesRequiringUserActionForPlayback = []
}

webView = WKWebView(frame: .zero, configuration: webConfiguration)
webView.uiDelegate = self
webView.navigationDelegate = self

Then, when you’re ready to start the Hosted Flow:

guard let url = URL(string: hostedFlowURL) else { return }
webView.load(URLRequest(url: url))

Key behavior summary

FeatureDescription
In-app hostingThe Socure Hosted Flow runs directly inside a full-screen WKWebView, delivering a seamless, native-feeling experience.
External linksTerms, privacy, and legal links open in SFSafariViewController to preserve user context and avoid disrupting the verification flow.
Media captureCamera access is granted natively, enabling document and selfie capture directly within the app.
New-window supporttarget="_blank" links are handled safely, ensuring navigation does not break the Hosted Flow experience.
Close buttonUsers can exit verification at any time and return cleanly to the app’s landing screen.
Minimal UI flowVerification begins immediately after tapping Verify Identity, with no intermediate or unnecessary screens.
Mobile renderingWeb content is forced into mobile mode to ensure correct layout, scaling, and interaction.
Inline mediaInline camera and video streams are enabled to support capture steps within the Hosted Flow.

Recommended UX enhancements

Not strictly required, but recommended:

  • A full-screen WebView anchored to the safe area.
  • A landing screen with branding and a clear “Verify Identity” call-to-action.
  • A close button to exit the Hosted Flow and return to your app.
  • Logic to open legal/terms/privacy links in Safari instead of inside the Hosted Flow WebView.

The sample implementation below follows these patterns.


Implementation walkthrough (ViewController.swift)

This section provides a production-ready walkthrough of a simple iOS integration that embeds Socure Hosted Flows inside a WKWebView.


Class definition and properties

class ViewController: UIViewController, WKUIDelegate, WKNavigationDelegate {
    var webView: WKWebView!
    var landingView: UIView!
    var verifyButton: UIButton!
    var closeButton: UIButton!

    let hostedFlowURL = "https://riskos.sandbox.socure.com/hosted/<flow-id>"
}

What this does:

  • WKUIDelegate: Handles UI-related behaviors such as new windows (target="_blank").
  • WKNavigationDelegate: Intercepts navigation events so you can:
    • Keep users inside the Hosted Flow.
    • Redirect legal or external links to Safari.

viewDidLoad: Initial setup

override func viewDidLoad() {
    super.viewDidLoad()
    self.view.backgroundColor = .systemBackground
    setupWebView()
    setupLandingScreen()
}

What this does:

On launch, the controller prepares both experiences:

  • A hidden WebView (Socure Hosted Flow).
  • A landing screen with your app’s UI and a Verify Identity button.

Configuring the WKWebView

func setupWebView() {
    let webConfiguration = WKWebViewConfiguration()
    let pref = WKWebpagePreferences()
    pref.preferredContentMode = .mobile
    webConfiguration.defaultWebpagePreferences = pref
    webConfiguration.allowsInlineMediaPlayback = true
    
     if #available(iOS 10.0, *) {
        config.mediaTypesRequiringUserActionForPlayback = []
    }

    webView = WKWebView(frame: .zero, configuration: webConfiguration)
    webView.uiDelegate = self
    webView.navigationDelegate = self
    webView.isHidden = true
    webView.backgroundColor = .white
    webView.scrollView.contentInsetAdjustmentBehavior = .never
    webView.translatesAutoresizingMaskIntoConstraints = false

    self.view.addSubview(webView)

    NSLayoutConstraint.activate([
        webView.topAnchor.constraint(equalTo: self.view.safeAreaLayoutGuide.topAnchor),
        webView.bottomAnchor.constraint(equalTo: self.view.bottomAnchor),
        webView.leadingAnchor.constraint(equalTo: self.view.leadingAnchor),
        webView.trailingAnchor.constraint(equalTo: self.view.trailingAnchor)
    ])

    addCloseButton()
}

What this does:

  • Mobile content mode: Ensures the Hosted Flow renders its mobile‑optimized UI.
  • Inline media playback: Required for camera‑based document and selfie capture.
  • No additional playback gestures (mediaTypesRequiringUserActionForPlayback = []): Prevents WebKit from blocking video/camera startup.
  • Full‑screen layout: Creates an immersive verification experience without scroll or inset issues.
  • Hidden by default: The WebView is only shown after the user taps Verify Identity.

Close button (exit Hosted Flow)

func addCloseButton() {
    closeButton = UIButton(type: .system)
    closeButton.setTitle("✕", for: .normal)
    closeButton.titleLabel?.font = UIFont.systemFont(ofSize: 24, weight: .bold)
    closeButton.backgroundColor = UIColor.black.withAlphaComponent(0.7)
    closeButton.setTitleColor(.white, for: .normal)
    closeButton.layer.cornerRadius = 20
    closeButton.translatesAutoresizingMaskIntoConstraints = false
    closeButton.addTarget(self, action: #selector(closeWebView), for: .touchUpInside)
    closeButton.isHidden = true

    self.view.addSubview(closeButton)

    NSLayoutConstraint.activate([
        closeButton.topAnchor.constraint(equalTo: self.view.safeAreaLayoutGuide.topAnchor, constant: 10),
        closeButton.trailingAnchor.constraint(equalTo: self.view.trailingAnchor, constant: -10),
        closeButton.widthAnchor.constraint(equalToConstant: 40),
        closeButton.heightAnchor.constraint(equalToConstant: 40)
    ])
}

What this does:

  • Provides a clear way for the user to exit the Hosted Flow and return to the app.
  • Only visible when the Hosted Flow is active.

Landing screen and “Verify Identity” button

func setupLandingScreen() {
    landingView = UIView(frame: self.view.bounds)
    landingView.backgroundColor = .systemBackground
    landingView.translatesAutoresizingMaskIntoConstraints = false
    self.view.addSubview(landingView)

    // Layout constraints ...

    let titleLabel = UILabel()
    titleLabel.text = "Your App Name"
    // styling...

    let subtitleLabel = UILabel()
    subtitleLabel.text = "Your App Description."
    // styling...

    verifyButton = UIButton(type: .system)
    verifyButton.setTitle("Verify Identity", for: .normal)
    // styling...
    verifyButton.addTarget(self, action: #selector(startVerification), for: .touchUpInside)

    // Add and constrain subviews...
}

What this does:

  • Represents your app’s native UI.
  • Acts as a clear, intentional entry point into identity verification.

Launching the Hosted Flow

@objc func startVerification() {
    landingView.isHidden = true
    webView.isHidden = false
    closeButton.isHidden = false
    self.view.bringSubviewToFront(closeButton)

    guard let url = URL(string: hostedFlowURL) else {
        return
    }

    webView.load(URLRequest(url: url))
}

What this does:

  • Hides your app UI.
  • Displays the Hosted Flow WebView.
  • Loads the Socure Hosted Flow URL.

Closing the Hosted Flow

@objc func closeWebView() {
    landingView.isHidden = false
    webView.isHidden = true
    closeButton.isHidden = true
    webView.stopLoading()
    self.view.bringSubviewToFront(landingView)
}

What this does:

  • Stops network activity.
  • Restores the app UI.
  • Leaves no residual web state visible to the user.

Navigation control and external links

Intercepting navigation

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

    // Check if this should open in Safari
    if shouldOpenInSafari(url: url, navigationType: navigationAction.navigationType) {
        let safariVC = SFSafariViewController(url: url)
        safariVC.modalPresentationStyle = .pageSheet
        present(safariVC, animated: true)
        decisionHandler(.cancel)
    } else {
        decisionHandler(.allow)
    }
}

What this does:

  • For most URLs, navigation is allowed inside the WebView.
  • For certain URLs (e.g., terms, privacy), you cancel WebView navigation and open the URL in SFSafariViewController instead.

Why open legal links in Safari?

  • Keeps the verification experience focused.
  • Prevents long legal documents from disrupting flow context.
  • Matches user expectations for external content.
func shouldOpenInSafari(url: URL, navigationType: WKNavigationType) -> Bool {
    // Only open in Safari if user clicked a link
    guard navigationType == .linkActivated else {
        return false
    }

    // Check for external documentation keywords
    let urlString = url.absoluteString.lowercased()
    return urlString.contains("term")
        || urlString.contains("privacy")
        || urlString.contains("policy")
        || urlString.contains("legal")
}

Handling target="_blank" links

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

    // Handle links with target="_blank"
    if navigationAction.targetFrame == nil,
       let url = navigationAction.request.url {

        if shouldOpenInSafari(url: url, navigationType: navigationAction.navigationType) {
            let safariVC = SFSafariViewController(url: url)
            present(safariVC, animated: true)
        } else {
            webView.load(navigationAction.request)
        }
    }

    return nil
}

What this does:

  • Ensures links that open in a new window (e.g., target="_blank") are handled gracefully:
    • Either opened in Safari, or
    • Loaded in the existing WebView.

Camera permissions (iOS 15+)

@available(iOS 15.0, *)
func webView(_ webView: WKWebView,
             decideMediaCapturePermissionsFor origin: WKSecurityOrigin,
             initiatedBy frame: WKFrameInfo,
             type: WKMediaCaptureType) async -> WKPermissionDecision {

    return .grant
}

What this does:

  • This delegate method automatically grants camera access to the web content from the given origin.
  • Combined with the correct NSCameraUsageDescription in Info.plist, this allows the Hosted Flow to:
    • Access the camera for document and selfie capture.
    • Use video streams as needed for verification.

Summary

To embed Socure Hosted Flows in an iOS app using WKWebView, you need:

  1. A Socure Hosted Flow URL.
  2. A properly configured WKWebView with:
    • Mobile content mode
    • Inline media playback
    • Navigation and UI delegates
  3. Camera permissions declared in Info.plist
  4. (Recommended) UX controls for:
    • Explicit entry into verification.
    • Exiting the flow.
    • Handling external legal/terms/privacy links in Safari.
    • Grant media capture permissions (iOS 15+).

Sample ViewController.swift already implements a clean, production-style pattern you can generalize in the documentation.


Full example implementation

Use this as a starting point for integrating Hosted Flows into your iOS app.

import UIKit
import WebKit
import SafariServices

class ViewController: UIViewController, WKUIDelegate, WKNavigationDelegate {

    // MARK: - UI Elements

    private var webView: WKWebView!
    private var landingView: UIView!
    private var verifyButton: UIButton!
    private var closeButton: UIButton!

    // MARK: - Hosted Flow URL

    /// TODO: Replace with your actual Socure Hosted Flow URL
    private let hostedFlowURL = "https://riskos.sandbox.socure.com/hosted/<flow-id>"

    // MARK: - Lifecycle

    override func viewDidLoad() {
        super.viewDidLoad()
        view.backgroundColor = .systemBackground

        setupWebView()
        setupLandingScreen()
    }

    // MARK: - WebView Setup

    private func setupWebView() {
        let config = WKWebViewConfiguration()
        let prefs = WKWebpagePreferences()
        prefs.preferredContentMode = .mobile
        config.defaultWebpagePreferences = prefs
        config.allowsInlineMediaPlayback = true
        
        if #available(iOS 10.0, *) {
		        webConfiguration.mediaTypesRequiringUserActionForPlayback = []
				}

        webView = WKWebView(frame: .zero, configuration: config)
        webView.uiDelegate = self
        webView.navigationDelegate = self
        webView.isHidden = true
        webView.backgroundColor = .white
        webView.scrollView.contentInsetAdjustmentBehavior = .never
        webView.translatesAutoresizingMaskIntoConstraints = false

        view.addSubview(webView)

        NSLayoutConstraint.activate([
            webView.topAnchor.constraint(equalTo: view.safeAreaLayoutGuide.topAnchor),
            webView.bottomAnchor.constraint(equalTo: view.bottomAnchor),
            webView.leadingAnchor.constraint(equalTo: view.leadingAnchor),
            webView.trailingAnchor.constraint(equalTo: view.trailingAnchor)
        ])

        setupCloseButton()
    }

    // MARK: - Close Button

    private func setupCloseButton() {
        closeButton = UIButton(type: .system)
        closeButton.setTitle("✕", for: .normal)
        closeButton.titleLabel?.font = UIFont.systemFont(ofSize: 24, weight: .bold)
        closeButton.setTitleColor(.white, for: .normal)
        closeButton.backgroundColor = UIColor.black.withAlphaComponent(0.7)
        closeButton.layer.cornerRadius = 20
        closeButton.translatesAutoresizingMaskIntoConstraints = false
        closeButton.isHidden = true
        closeButton.addTarget(self, action: #selector(closeWebView), for: .touchUpInside)

        view.addSubview(closeButton)

        NSLayoutConstraint.activate([
            closeButton.topAnchor.constraint(equalTo: view.safeAreaLayoutGuide.topAnchor, constant: 10),
            closeButton.trailingAnchor.constraint(equalTo: view.trailingAnchor, constant: -10),
            closeButton.widthAnchor.constraint(equalToConstant: 40),
            closeButton.heightAnchor.constraint(equalToConstant: 40)
        ])
    }

    // MARK: - Landing Screen

    private func setupLandingScreen() {
        landingView = UIView()
        landingView.backgroundColor = .systemBackground
        landingView.translatesAutoresizingMaskIntoConstraints = false

        view.addSubview(landingView)

        NSLayoutConstraint.activate([
            landingView.topAnchor.constraint(equalTo: view.topAnchor),
            landingView.bottomAnchor.constraint(equalTo: view.bottomAnchor),
            landingView.leadingAnchor.constraint(equalTo: view.leadingAnchor),
            landingView.trailingAnchor.constraint(equalTo: view.trailingAnchor)
        ])

        // Title label
        let titleLabel = UILabel()
        titleLabel.text = "Your App Name"
        titleLabel.font = UIFont.systemFont(ofSize: 42, weight: .bold)
        titleLabel.textAlignment = .center
        titleLabel.translatesAutoresizingMaskIntoConstraints = false

        // Subtitle label
        let subtitleLabel = UILabel()
        subtitleLabel.text = "Your App Description."
        subtitleLabel.font = UIFont.systemFont(ofSize: 16, weight: .regular)
        subtitleLabel.textColor = .secondaryLabel
        subtitleLabel.textAlignment = .center
        subtitleLabel.numberOfLines = 0
        subtitleLabel.translatesAutoresizingMaskIntoConstraints = false

        // Verify Identity button
        verifyButton = UIButton(type: .system)
        verifyButton.setTitle("Verify Identity", for: .normal)
        verifyButton.titleLabel?.font = UIFont.systemFont(ofSize: 18, weight: .semibold)
        verifyButton.backgroundColor = .systemBlue
        verifyButton.setTitleColor(.white, for: .normal)
        verifyButton.layer.cornerRadius = 25
        verifyButton.translatesAutoresizingMaskIntoConstraints = false
        verifyButton.addTarget(self, action: #selector(startVerification), for: .touchUpInside)

        landingView.addSubview(titleLabel)
        landingView.addSubview(subtitleLabel)
        landingView.addSubview(verifyButton)

        NSLayoutConstraint.activate([
            titleLabel.centerXAnchor.constraint(equalTo: landingView.centerXAnchor),
            titleLabel.centerYAnchor.constraint(equalTo: landingView.centerYAnchor, constant: -120),

            subtitleLabel.topAnchor.constraint(equalTo: titleLabel.bottomAnchor, constant: 16),
            subtitleLabel.centerXAnchor.constraint(equalTo: landingView.centerXAnchor),
            subtitleLabel.leadingAnchor.constraint(equalTo: landingView.leadingAnchor, constant: 40),
            subtitleLabel.trailingAnchor.constraint(equalTo: landingView.trailingAnchor, constant: -40),

            verifyButton.topAnchor.constraint(equalTo: subtitleLabel.bottomAnchor, constant: 50),
            verifyButton.centerXAnchor.constraint(equalTo: landingView.centerXAnchor),
            verifyButton.widthAnchor.constraint(equalToConstant: 260),
            verifyButton.heightAnchor.constraint(equalToConstant: 50)
        ])
    }

    // MARK: - Start / Close Hosted Flow

    @objc private func startVerification() {
        guard let url = URL(string: hostedFlowURL) else {
            print("Invalid Hosted Flow URL")
            return
        }

        landingView.isHidden = true
        webView.isHidden = false
        closeButton.isHidden = false
        view.bringSubviewToFront(closeButton)

        webView.load(URLRequest(url: url))
    }

    @objc private func closeWebView() {
        webView.stopLoading()
        webView.isHidden = true
        closeButton.isHidden = true
        landingView.isHidden = false
        view.bringSubviewToFront(landingView)
    }

    // MARK: - WKNavigationDelegate (External Links)

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

        guard let url = navigationAction.request.url else {
            decisionHandler(.allow)
            return
        }

        // Only handle user-tapped links
        if navigationAction.navigationType == .linkActivated,
           shouldOpenInSafari(url: url) {

            let safariVC = SFSafariViewController(url: url)
            safariVC.modalPresentationStyle = .pageSheet
            present(safariVC, animated: true)
            decisionHandler(.cancel)
            return
        }

        decisionHandler(.allow)
    }

    /// Decide whether a URL should open externally in Safari
    private func shouldOpenInSafari(url: URL) -> Bool {
        let s = url.absoluteString.lowercased()
        return s.contains("privacy")
            || s.contains("policy")
            || s.contains("legal")
            || s.contains("term")
    }

    // MARK: - WKUIDelegate (target="_blank" Support)

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

        // Handle links that try to open in a new window (target="_blank")
        if navigationAction.targetFrame == nil,
           let url = navigationAction.request.url {

            if shouldOpenInSafari(url: url) {
                let safariVC = SFSafariViewController(url: url)
                present(safariVC, animated: true)
            } else {
                webView.load(navigationAction.request)
            }
        }
        return nil
    }

    // MARK: - Camera / Media Permissions (iOS 15+)

    @available(iOS 15.0, *)
    func webView(_ webView: WKWebView,
                 decideMediaCapturePermissionsFor origin: WKSecurityOrigin,
                 initiatedBy frame: WKFrameInfo,
                 type: WKMediaCaptureType) async -> WKPermissionDecision {

        // You can add origin checks here if desired
        return .grant
    }
}