Integrating Identity Verification in a Mobile App WebView: Camera, Inline Media and File Input
Run the verification widget inside an iOS WKWebView or an Android WebView: two permission layers, inline camera preview, file choosers, navigation, lifecycle and a test matrix.
Your product is a mobile app, your identity check is a web widget, and the two have to meet on a phone without the user noticing the seam. The widget needs the camera, a live preview that stays inside the page, and sometimes a way to pick a file. What a WebView does with each of those by default differs by platform: on iOS an absent capture delegate answers prompt, inline playback defaults to false on iPhone and true on iPad, and file uploads are enabled by default, while on Android the WebView has JavaScript and DOM storage off, requires a user gesture for media, cancels all file requests and denies a camera request that no handler answers. This guide is for the mobile developer who owns that seam, and it answers one question: what has to be configured and tested on iOS and on Android so that the verification widget runs inside the app, and how does the app find out how the check ended?
A scope note comes first, because it shapes every section. The NeuroVision documentation covers a web widget. It does not describe a separate native mobile SDK, and the documentation says so itself: to use the widget in an app, you show the page that hosts it in a WebView, or you open that page in the system browser. Everything below follows from that. The widget is a web page, your app is the container, and your job is to give the container the powers the page needs. For the browser side of the same widget, read integrating identity verification with a web SDK, step by step, and for how the result reaches your server, read receiving verification results by polling or webhook.
The platform facts come from the Web SDK documentation and its page on mobile apps and WebView. The platform behaviour of WKWebView and the Android WebView comes from the Apple and Android developer documentation, which is listed at the end. Samples use placeholders, such as your-domain.example, which you replace with your own values.
Decide first: WebView or system browser
Before any configuration, decide whether the check must run inside your app at all. The documentation offers three ways to host the widget in a mobile context, and the right one depends on what you need to control.
| Option | What you gain | What you take on |
|---|---|---|
| The page in a WebView inside your app | The check looks like part of the app and the user never leaves it. | Two permission layers on each platform, inline media settings, navigation rules and lifecycle handling, all described below. |
| A direct link in the system browser, through Safari View Controller on iOS or Custom Tabs on Android | The camera works there as in a regular browser, so there is nothing to configure for it. | You leave the app visually, and you need a way back, such as a redirect address that your app intercepts. |
| The page inside an iframe of another page | Useful when the widget is one part of a larger page. | The frame needs allow="camera", and the inline mode of the widget does not work inside a cross-origin iframe. |
The documentation calls the system browser the simplest path, with no WebView setup. If your reason for a WebView is only appearance, weigh that cost honestly: every setting below is code that you maintain across operating system releases. If your reason is that the check must stay inside the app, for example because a flow has to continue seamlessly after the check, a WebView is justified, and the rest of this guide is the checklist.
A related case is the link that arrives from outside. A link opened inside the built-in browser of a messenger or a social network lands in an environment you do not control, where camera access can be restricted. The documentation's advice is to ask the user to open the link in the system browser. In your own app, use Safari View Controller or Custom Tabs for that case.
What must be true for every option
Three requirements apply to the WebView and to the system browser alike.
- The page opens over HTTPS from your domain. A local file or a custom URL scheme does not work, according to the documentation, which adds that the widget runs only on allowed domains. There is a subtlety worth knowing. The browser rule for secure contexts is wider than the platform rule: the MDN documentation lists a file URL among the contexts a browser treats as secure. So a page loaded from a local file may well receive a camera prompt, and the reason it still fails with this widget is the platform's own rule that it runs only on allowed domains over HTTPS. Do not bundle the page in the app. Host it.
- The domain is in the allowed domains. Add the host of the page to the list in the dashboard, with the mask rule in mind:
*.example.comallows subdomains and notexample.comitself. A change takes effect within two minutes. Keep the staging host as a separate entry. - The user grants the app camera access. The only permission the user must give the widget is camera access during capture. On a phone that permission has two layers, and the next section explains why that matters.
Two permission layers
The camera in a WebView is guarded twice, and the most common integration failure is to configure one layer and forget the other.
Layer one is the operating system. The app asks the user for camera access, and the system remembers the answer. On iOS this needs a usage description in the app's property list. On Android it needs a declared permission and a request at run time.
Layer two is the WebView. When the page asks for the camera, the WebView does not simply pass the request on. It asks its host, your app, through a delegate on iOS and through a chrome client on Android, whether this web content may use the device. Your code answers. If you do not answer, you get the default, and the default does not give you a working camera on Android.
The sequence for a request is: the user taps the button in the widget, the page asks the browser engine for the camera, the WebView asks your delegate whether that origin may have it, your delegate decides, and the system checks that your app has the operating system permission. A failure at either layer ends in the same symptom for the user, a widget that shows the screen asking to allow the camera. The table of symptoms near the end of this guide separates the two.
What the page sees on failure is documented by MDN: the media capture call is rejected with a NotAllowedError, which is thrown when the context is insecure, when the user or the system has denied access, or when the Permissions Policy blocks the feature. A missing device gives NotFoundError instead. The widget handles these for you and shows its own screens, so you do not code against these errors, but they are what your logs and remote debugging will show.
iOS: WKWebView
The usage description
Add the NSCameraUsageDescription key to the app's Info.plist. Apple describes it as a message that tells people why the app is requesting access to the camera, and says the key is required if the app uses APIs that access the camera. Apple's guide on requesting authorization goes further: the key must be present before the app requests authorization or attempts to use a capture device, and otherwise the system terminates the app. A missing key is therefore not a cosmetic omission. It ends the process.
<key>NSCameraUsageDescription</key>
<string>We use the camera to photograph your document and to take a selfie, so that we can verify your identity.</string>
Write the text for the user. It appears in the system alert, and Apple's guidance is that the message should explain why the app needs to capture media, so that the user feels confident granting permission. Name the purpose in plain words.
The system alert appears the first time. Apple's documentation says the system remembers the response, so later uses do not show the alert again, and that the user can change the setting in Settings under Privacy. Plan for the user who denied it once: your app will not get a second prompt, and the check cannot start until the user changes the setting. You can read the current state before opening the WebView with the authorization status call for video, whose documented values include not determined, denied and authorized, and request access only when the state is not determined. Do it at a moment that makes sense to the user, such as when they tap the button that starts the check, and not at launch.
Inline media playback
The camera preview is a video, and on iPhone a video can leave the page and take over the screen. Apple's documentation for allowsInlineMediaPlayback describes the property as the choice between playing HTML5 videos inline and using the native full-screen controller, and it adds that when a video element is added to an HTML document on iPhone, the document must also carry the playsinline attribute. The default value is false on iPhone and true on iPad. Apps created before iOS 10.0 must use the webkit-playsinline attribute instead.
Two things follow for you. The property is yours to set, on the configuration, before you create the web view. The attribute belongs to the page, so with a widget loaded as documented, the app side is the one you control. If the preview still opens full screen with the property set, check the page markup next, and then ask the platform team. The platform documentation's symptom for the unset property is the same: the preview opens in a full-screen player, or does not appear.
The documentation's sample also sets mediaTypesRequiringUserActionForPlayback to an empty set. Apple describes this property as the media types that require a user gesture to begin playing. Clearing it lets the preview start without an extra tap, which suits a camera preview that the user has already asked for.
Granting the camera to your page
The delegate method that answers the WebView's question is webView(_:requestMediaCapturePermissionFor:initiatedByFrame:type:decisionHandler:). Apple describes it as the method that determines whether a web resource, which the security origin object describes, can access the device's microphone audio and camera video. Two details matter.
First, if you do not implement it, the system returns prompt. The decision type has three values, grant, deny and prompt, and prompt asks the user. The documentation's sample grants your own host and returns prompt for the rest, which stops the double prompt for your own page. The method and the decision type are available from iOS 15.0. If your app supports earlier iOS versions, guard the delegate method with an availability check, and expect the older behaviour on those versions: there is no delegate decision, so test the camera on the oldest version you ship.
Second, the origin object identifies the host name, the protocol and the port number. A check on the host alone is looser than the object allows, and so is a check on host and protocol. A stricter check compares all three with the origin you intend, and denies, not prompts, for anything that is not yours. A foreign origin inside your WebView is not a case where you want to hand the question to the user. Treat a port of zero as the default port of the protocol, as the comment in the sample says, and test the behaviour on the iOS versions you support.
let config = WKWebViewConfiguration()
config.allowsInlineMediaPlayback = true
config.mediaTypesRequiringUserActionForPlayback = []
let webView = WKWebView(frame: .zero, configuration: config)
webView.uiDelegate = self
webView.navigationDelegate = self
// WKUIDelegate
@available(iOS 15.0, *)
func webView(_ webView: WKWebView,
requestMediaCapturePermissionFor origin: WKSecurityOrigin,
initiatedByFrame frame: WKFrameInfo,
type: WKMediaCaptureType,
decisionHandler: @escaping (WKPermissionDecision) -> Void) {
let port = origin.port == 0 ? 443 : origin.port // zero: the default port of the protocol
let mine = origin.protocol == "https"
&& origin.host == "your-domain.example"
&& port == 443
decisionHandler(mine && type == .camera ? .grant : .deny)
}
If the widget ever requests more than the camera, the type check above will deny it, and your test will show it. Loosen the condition to what the test shows is needed, and no further.
File input on iOS
If a step in your scenario lets the user pick a file, the web view's file panel comes into play. Apple's documentation for the delegate method webView(_:runOpenPanelWith:initiatedByFrame:completionHandler:) says that, on iOS, file uploads are enabled by default if you do not implement the method. The method itself, which lets you customize the panel, is available from iOS 18.4, so do not plan around it for older versions. The default upload behaviour is the older and wider one: on iOS a plain file input works without code, and the customization method is an option for recent versions only. The platform documentation names the camera as the only permission the user must give, so treat file selection as something to verify in your scenario and not as a documented requirement.
Navigation
Set a navigation delegate. Apple describes WKNavigationDelegate as the methods for accepting or rejecting navigation changes, and its description gives restricting navigation from specific links as an example of use. For the verification screen, allow your own host and decide deliberately about everything else. A verification page that can be navigated to an arbitrary site from a link is a page where your camera grant has a longer reach than you intended.
Android: WebView
Manifest and run-time request
Declare the camera permission in AndroidManifest.xml, and the internet permission, since the Android documentation states that the app must have internet access for a WebView to load a page.
<uses-permission android:name="android.permission.INTERNET" />
<uses-permission android:name="android.permission.CAMERA" />
The camera is a run-time permission, so ask for it before you show the verification screen. The Android guidance on run-time permissions gives the principles in order. Explain why your app needs the permission before the system dialog appears, because the system dialog says what permission is wanted and not why. If the permission check returns denied, call shouldShowRequestPermissionRationale(), and if it returns true, show an educational screen that describes why the feature needs the permission, with an option to cancel it. If the user denies, degrade gracefully, and respect the decision. The guidance says not to link to system settings in an effort to convince the user to change their decision. For identity verification, the graceful degradation is a screen that says the check needs the camera and offers another way, such as opening the check in the system browser, where the browser asks for the camera itself. If you want a route to another device, build it yourself and say so in your product. The platform's QR hand-off starts on a computer and moves the user to a phone, so it does not serve a phone user whose camera is unavailable.
Settings the page needs
The Android documentation gives defaults that matter. JavaScript execution is off by default, DOM storage is off by default, and the setting that requires a user gesture to play media is on by default. The widget needs the first two, and a camera preview needs the third turned off.
webView.settings.apply {
javaScriptEnabled = true
domStorageEnabled = true
mediaPlaybackRequiresUserGesture = false
}
There is one more thing the documentation notes about links. By default, when the user taps a link in a WebView, Android launches an app that handles URLs, usually the default web browser, and you can override that with a WebViewClient and shouldOverrideUrlLoading so that links open in the WebView, or are refused. Set a WebViewClient and decide which hosts stay in the view. It is the Android counterpart of the navigation delegate on iOS.
Granting the camera to your page
The WebView delivers the page's request to WebChromeClient.onPermissionRequest. The Android documentation describes the permission request as the object used when web content requests access to protected resources, with the origin and the list of resources, and says that either grant() or deny() must be called on the UI thread. The platform documentation states the consequence: without a handler, the WebView denies the page even when the app itself holds the camera permission. This is the second layer.
The Android documentation also includes an explicit warning that is easy to miss. New protected resources may be requested in future versions of the WebView, even on an older Android release, so to avoid granting a request for a new permission by accident, pass to grant() the specific resources you intend to grant, and never write code that grants whatever was asked. The documentation labels the pattern grant(request.getResources()) as wrong.
webView.webChromeClient = object : WebChromeClient() {
override fun onPermissionRequest(request: PermissionRequest) {
val video = PermissionRequest.RESOURCE_VIDEO_CAPTURE
val origin = request.origin // a Uri
val port = if (origin.port == -1) 443 else origin.port // -1: no port in the address
val mine = origin.scheme == "https" &&
origin.host == "your-domain.example" &&
port == 443
activity.runOnUiThread {
if (mine && request.resources.contains(video)) {
request.grant(arrayOf(video)) // only what you intend
} else {
request.deny()
}
}
}
override fun onPermissionRequestCanceled(request: PermissionRequest) {
// The page withdrew the request. Nothing to undo if you answered nothing yet.
}
}
The same two-step logic as on iOS applies. The whole origin is checked, scheme, host and port, and what is granted is the camera and nothing else.
File input on Android
Here the default is the opposite of iOS. The Android documentation for onShowFileChooser says the default behaviour is that the WebView cancels all file requests. If any step in your scenario shows a file input, you must override the method, return true, and keep the callback until the user has chosen.
override fun onShowFileChooser(
webView: WebView,
filePathCallback: ValueCallback<Array<Uri>>,
params: FileChooserParams
): Boolean {
pendingChooser?.onReceiveValue(null) // never leave an earlier callback hanging
pendingChooser = filePathCallback
launcher.launch(params.createIntent()) // your activity result handling
return true
}
// In the result handler: pendingChooser?.onReceiveValue(FileChooserParams.parseResult(resultCode, data))
// pendingChooser = null
Two rules from the documentation deserve attention. A callback invoked with null cancels the request, so keep one pending callback at a time and always answer it, with a chosen file or with null. And the documentation warns that the WebView does not restrict the chosen files and can access everything your app can access, so when the files come from an untrusted source such as a third-party app, checking what the returned addresses refer to is your app's responsibility.
A note on the capture attribute
HTML has a capture attribute for file inputs. MDN describes it as specifying that a new file should be captured and which device should be used, with the values user and environment, and marks it as not Baseline, because it does not work in some of the most widely used browsers. In a WebView the outcome depends on the engine and the host app. If your scenario uses a file input with capture, test it on each platform you support and do not assume it opens a camera.
Getting the result back into the app
The widget finishes inside the WebView. Your app has to know when to close it and what to show next. There are two separate questions in that sentence, and they have different answers.
When to close the WebView is an interface question. The page is yours, so it can tell the app that the widget has closed or finished, for example by navigating to an address that only your app understands, which your navigation delegate on iOS or your WebViewClient on Android intercepts. The platform documentation describes the same idea for the direct link flow in the system browser: you set a mobile redirect address in the scenario, such as a universal link or an app link on your domain, and when the check ends the widget goes to that address with the session identifier and the final status appended, and the app intercepts the address, closes the browser and shows the next step. For a WebView you may use the same interception with an address from your own page. Whichever you use, it is a signal for the screen.
What the result is is a server question. The documentation says directly that the status in the address is a hint for the interface, and that the result is taken on your server, from the webhook or from the session status. Do not let the app decide that a person is verified because a WebView navigated to an address. The app can ask your backend, which reads the platform, and the backend returns your own decision. The design for that backend is in the article on polling and webhooks.
One more relation to keep in mind: the client key. It is issued by your backend for the signed-in user, and the page receives it encrypted. The app should obtain it from your backend through an authenticated call, and never hold the secret key of the scenario or the API token. Anything inside the app package can be extracted.
Lifecycle: what happens when the app is interrupted
A check takes a minute or two of attention, and a phone is interrupted constantly. Three situations need a plan.
- The app goes to the background. The camera stops, and the page may be suspended. When the user returns, the WebView may show the widget in a stopped state. Test it, and decide whether you reload the screen or leave it.
- The device rotates. The widget works in portrait on a phone and asks the user to turn the phone back if they rotate it. Depending on how your Android activity handles configuration changes, a rotation can recreate the activity, and with it the WebView. Locking the verification screen to portrait removes the case. That is a design choice of yours, not a platform requirement.
- The app is killed mid-check. The session lives on the platform until its lifetime runs out, and the widget has a setting for this: with the sticky session option on, it remembers the client key per scenario in the browser for 15 minutes, and reopening within that time resumes the previous session. In a WebView this depends on page storage surviving, so keep DOM storage on and test whether a restart really resumes. If it does not, the user starts a new session, and your backend sees a second session for the same client key.
The same logic applies to the network. When the connection drops, the widget shows a screen with a retry button, so the app does not need to handle it, but a WebView that fails to load the page at all is your responsibility: handle the load failure with a message and a retry, and do not leave a white screen.
If the camera does not start
The table extends the one in the documentation with the layer where each cause lives.
| Symptom | Layer | What to check |
|---|---|---|
| The widget shows the screen that asks to allow the camera, and no system prompt ever appeared | Operating system or WebView | On iOS, the usage description key and the state of the permission in Settings. On Android, the manifest entry and the run-time request. |
| iOS: the system prompt appeared and was denied | Operating system | The system remembers the answer and does not show the alert again. The user must change it in Settings under Privacy. Read the authorization status before opening the check, explain, and offer another path such as the system browser. |
| Android: the system prompt appeared and was denied | Operating system | Check shouldShowRequestPermissionRationale(). If it returns true, show your explanation and request again when the user chooses. If it returns false after a denial, respect the decision, degrade gracefully and offer another path. |
| The system prompt is fine, but the widget still asks to allow the camera | WebView | On Android, the onPermissionRequest handler. On iOS, the media capture delegate, and whether it answers deny for your own origin by a too strict test. |
| On iOS the preview opens full screen or is black | WebView configuration | allowsInlineMediaPlayback set before the web view is created, and the playsinline attribute on the page. |
| The preview does not start by itself on Android | WebView settings | The setting that requires a user gesture for media. |
| The widget does not open at all | Page | HTTPS, and the domain in the allowed domains. The change takes effect within two minutes. |
| A blank page after a redirect | Navigation | The navigation delegate or shouldOverrideUrlLoading refusing a host that the flow needs. |
| It fails only inside the browser of a messenger | Environment | Ask the user to open the link in the system browser. |
| A file input does nothing on Android | WebView | onShowFileChooser is not overridden, so the default cancels the request. |
A test matrix
Test on real devices, and cover the range your app supports, from the oldest operating system version to the newest. An emulator can run the page, and a camera is where emulators differ most from phones.
- First run. A clean install, no earlier permission. The system prompt appears once, with your text, and the check starts after it.
- Deny, then retry. Deny the prompt, return to the app, and confirm that the app explains what happened and offers another path.
- Revoke in settings. Grant, then revoke the permission in the system settings, and reopen the check. The app must notice and explain.
- Foreign origin. Navigate the WebView to another host in a test build, and to your own host on another port, and confirm that the camera is denied and the navigation is refused in both cases.
- Interrupt. Background the app in the middle of a capture, return, and note the state. Do it at each step of the scenario.
- Rotate and lock. Rotate the device on the verification screen, and confirm that the lock or the widget's prompt works.
- Kill and resume. Kill the app mid-check and reopen within the sticky session window. Note whether the session resumes.
- Offline. Open the check without a connection, and confirm the retry path on a page that fails to load.
- File input. If your scenario shows one, choose a file on each platform and cancel once. A cancelled chooser must not freeze the input.
- Result. Complete a check and confirm that the app learns the outcome from your backend and not from the address alone.
- Messenger link. Open the link inside a messenger and confirm the system browser hand-off.
Where this sits in a verification flow
The NeuroVision Web SDK is a branded widget that runs in current desktop and mobile browsers in 90+ languages, which is why a WebView or the system browser on a phone is a supported way to use it. The scenario editor in the dashboard sets the steps, the languages and the redirect addresses, and the webhooks tell your backend when a session completes. To discuss your app and your flow, contact the team.
Sources
All sources were fetched on 27 October 2026.
- allowsInlineMediaPlayback, Apple Developer Documentation.
- webView(_:requestMediaCapturePermissionFor:initiatedByFrame:type:decisionHandler:) and WKPermissionDecision, Apple Developer Documentation.
- webView(_:runOpenPanelWith:initiatedByFrame:completionHandler:), Apple Developer Documentation.
- WKNavigationDelegate, Apple Developer Documentation.
- NSCameraUsageDescription and Requesting authorization to capture and save media, Apple Developer Documentation.
- WebChromeClient, PermissionRequest and WebSettings, Android Developers.
- Build web apps in WebView and Request runtime permissions, Android Developers.
- Secure contexts, MediaDevices: getUserMedia() and the capture attribute, MDN Web Docs.