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?
Embed Socure Hosted Flows without building native user forms and document capture UI.
Keep users in-app while leveraging a fully hosted, compliant, and frequently updated web experience.
A single code path across platforms: reuse your web-based identity flow logic and configurations.
Rapid iteration on copy, styling, or flow variants without redeploying the app.
Before you start
Make sure you have the following:
A Socure Hosted Flow URL (Sandbox or Production), for example:
let hostedFlowURL = "https://riskos.sandbox.socure.com/hosted/{flow-id}"Required app capabilities
-
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.
- Add the following key to your app’s Info.plist, along with a clear reason for requesting access:
-
HTTPS:
- Socure RiskOS™ endpoints use HTTPS.
- No App Transport Security (ATS) exceptions or domain whitelisting are required for Sandbox or Production.
-
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:
- Use
WKWebViewwith mobile-friendly settings. - Allow inline media playback for camera and video streams.
- Assign navigation & UI delegates.
- Load the Socure Hosted Flow URL.
- (Recommended) Handle external links in Safari.
- (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 = selfThen, 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
| Feature | Description |
|---|---|
| In-app hosting | The Socure Hosted Flow runs directly inside a full-screen WKWebView, delivering a seamless, native-feeling experience. |
| External links | Terms, privacy, and legal links open in SFSafariViewController to preserve user context and avoid disrupting the verification flow. |
| Media capture | Camera access is granted natively, enabling document and selfie capture directly within the app. |
| New-window support | target="_blank" links are handled safely, ensuring navigation does not break the Hosted Flow experience. |
| Close button | Users can exit verification at any time and return cleanly to the app’s landing screen. |
| Minimal UI flow | Verification begins immediately after tapping Verify Identity, with no intermediate or unnecessary screens. |
| Mobile rendering | Web content is forced into mobile mode to ensure correct layout, scaling, and interaction. |
| Inline media | Inline 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)
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
viewDidLoad: Initial setupoverride 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
WKWebViewfunc 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
SFSafariViewControllerinstead.
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
target="_blank" linksfunc 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
NSCameraUsageDescriptioninInfo.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:
- A Socure Hosted Flow URL.
- A properly configured
WKWebViewwith:- Mobile content mode
- Inline media playback
- Navigation and UI delegates
- Camera permissions declared in
Info.plist - (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
}
}Updated about 1 month ago
