Face ID & Fingerprint Login in WebView App: Complete Guide
Biometric auth works beautifully in a native app. Then someone asks you to load a page inside a WebView, and the fingerprint button does nothing. The Face ID prompt never appears. window.PublicKeyCredential is undefined. Welcome to the WebView biometric gap — and this guide is your way out.
Below you'll find a platform-agnostic, security-sound implementation path for 2025–2026, covering iOS (WKWebView), Android (WebView), and the major cross-platform frameworks: React Native, Flutter, and Capacitor. Jump to what you need:
- What biometric login in a WebView actually means
- Why it doesn't "just work"
- What changed in 2025–2026
- Step-by-step implementation (iOS, Android, RN, Flutter, Capacitor)
- Passkeys: the 2025 best practice
- Common mistakes and fixes
- Security architecture
- Platform comparison matrix
- FAQ
What Is Biometric Authentication in a WebView App?
Biometric login in a WebView app means the fingerprint or face scan happens on the native side of your app — not inside the web page itself. A JavaScript bridge connects the two.
This is a critical distinction. When a user opens Safari on iOS or Chrome on Android and visits a site with WebAuthn enabled, the browser handles everything: it calls the platform authenticator, signs the challenge, and returns the credential. That pipeline is not available to a WebView component by default. WebView ≠ browser, even though both display HTML.
How Face ID and Fingerprint Scanning Work Natively
On iOS, the LocalAuthentication framework exposes a single class — LAContext — with one key method: evaluatePolicy(_:localizedReason:). You pick a policy (biometrics-only, or biometrics-with-passcode-fallback), provide a reason string the OS shows the user, and get a boolean result back.
On Android, BiometricPrompt from Jetpack (androidx.biometric) is the unified API. Before showing the prompt, you check BiometricManager.canAuthenticate() to confirm enrollment and hardware capability. The OS then handles Face Unlock, fingerprint, and iris scanning through a single dialog.
Both platforms abstract the hardware entirely. Your code doesn't care whether it's Face ID, Touch ID, or an under-display fingerprint sensor — the framework API is the same.
How WebView Differs From an Embedded Browser
The short version:
- WKWebView (iOS) runs in an isolated process. It does not inherit Safari's entitlements.
window.PublicKeyCredentialis absent by default below iOS 18, and even on iOS 18 it's gated by a specific entitlement and Associated Domains setup. - Android WebView shares Chromium's rendering engine with Chrome, but ships without Chrome's full feature set. The
navigator.credentialsAPI is disabled in standard WebView contexts until Android 14+ with WebView 120+. - SFSafariViewController (iOS) and Chrome Custom Tabs (Android) do give you a real browser context with full WebAuthn support — but they take the user out of your app's UI shell, which may or may not be acceptable.
Key Terms You Need Before Implementing
| Term | Plain-English Definition |
|---|---|
| WebAuthn / FIDO2 | W3C API that lets a browser use a device authenticator (Face ID, a hardware key, etc.) |
| Platform Authenticator | The built-in device biometric: Face ID, Touch ID, Android Biometric |
| JavaScript Bridge | A channel between native app code and WebView JavaScript |
| Relying Party (RP) | Your server that registers and verifies WebAuthn credentials |
| Passkey | A FIDO2 credential tied to a platform authenticator, synced via cloud |
LAContext |
iOS class that triggers Face ID / Touch ID prompts |
BiometricPrompt |
Android Jetpack class that surfaces the system biometric dialog |
Why Native Biometric Doesn't "Just Work" in WebView
Face ID doesn't work in your WebView because WebView components — unlike full browsers — do not automatically expose the Web Authentication API (WebAuthn) to page JavaScript. You need a native bridge to route those calls through the operating system.
The WebAuthn Access Gap: What Gets Blocked and Why
The two core WebAuthn calls — navigator.credentials.create() and navigator.credentials.get() — land on undefined inside a standard WebView. You can verify this in seconds:
// Quick diagnostic — paste in your WebView's JS console
if (window.PublicKeyCredential) {
console.log("WebAuthn supported ✅");
} else {
console.log("WebAuthn NOT available — bridge required ❌");
}
On iOS, the reason is WKWebView's process isolation model. WKWebView runs in a sandboxed process that doesn't share entitlements with the main app or with Safari. Apple controls which APIs are exposed. On Android, the WebView Chromium base intentionally omits the credential management APIs that Chrome enables by default.
Security Reasons Behind the Restrictions (Not a Bug)
This isn't an oversight — it's intentional. If any arbitrary WebView could access FIDO authenticators, a malicious app could load a phishing page in a WebView and request a legitimate passkey credential for a domain the app doesn't own.
WebAuthn's security model depends on origin binding: the credential is cryptographically tied to a specific relying party domain. Inside a WebView, enforcing that origin-to-app binding is much harder. The OS doesn't know whether myapp.com loaded in a WebView is genuinely your app or an attacker's. Associated Domains (iOS) and Digital Asset Links (Android) are the mechanisms that re-establish that trust — which is exactly why passkeys require them.
Three Architectural Approaches
Pick one based on your target OS and deployment constraints:
- Native Bridge — Intercept WebAuthn calls (or custom JS messages) in the native layer, run
LAContext/BiometricPrompt, and return the result to JavaScript. Works on all supported OS versions. Requires native code per platform. - SFSafariViewController / Chrome Custom Tabs — Hand off to a real browser context for the authentication step. Full WebAuthn support, zero bridge code. Downside: visible context switch, limited control over the browser UI.
- Passkeys via App Association — Configure Associated Domains (iOS) / Digital Asset Links (Android) so the OS brokers the credential directly from within the WebView. Requires iOS 18+ or Android 14+, but produces the cleanest UX with no custom bridge.
What Changed in 2025–2026: Biometric Auth Updates You Must Know
The biggest change is that both platforms now offer partial native WebAuthn support inside WebView — but with conditions that matter. Here's the platform-by-platform breakdown.
iOS / WKWebView Updates (2025–2026)
iOS 18 (late 2024) and subsequent patches introduced limited PublicKeyCredential support inside WKWebView, gated by the com.apple.developer.web-browser-engine.webcontent entitlement and a correctly configured Associated Domains setup. "Limited" means it works for first-party domains in the happy path; it does not work for third-party or iframe contexts.
iOS 19 (2025 preview) improves Passkey AutoFill inside WKWebView when Associated Domains are configured correctly. ASAuthorizationController can now be triggered directly from a WKWebView userContentController message handler, so you no longer need to leave the WebView context to show the passkey sheet. For anything below iOS 18, a LocalAuthentication bridge remains the safe, broadly compatible choice.
Android WebView & Chrome Updates (2025–2026)
Chrome 120+ Credential Manager API: WebView-backed apps targeting Android 14+ can now route biometric requests through Android's CredentialManager (androidx.credentials:credentials:1.3.0) in targeted scenarios — potentially without a custom JS bridge.
Android 15 Biometric API changes introduced stronger cryptographic backing and a mandatory LSKF (Lock Screen Knowledge Factor) enrollment check, meaning users must have a PIN, pattern, or password set before biometrics can be used.
WebView 120+ promoted the enable-webauthn and enable-passkeys-webview flags to stable on targeted OEM builds — but OEM variation means you cannot rely on this universally.
Digital Asset Links version 2 brings stricter domain association and an updated fingerprint format. If you're using Android passkeys, check your assetlinks.json against the current spec.
Cross-Platform Framework Updates
| Framework | Key 2025 Change |
|---|---|
| React Native 0.74/0.75 | New Architecture (Fabric + TurboModules) changes how biometric bridge modules register; legacy bridge is on a deprecation timeline |
expo-local-authentication 14.x |
Expo SDK 52 compatible; supports RN New Architecture |
react-native-biometrics 3.x |
Updated API surface; check Expo compatibility before use |
Flutter 3.22+ / local_auth 2.3.x |
isDeviceSupported() is now async; watch for conflicts with Android predictive back gesture |
Capacitor 6.x / @capacitor-community/biometric-auth v5 |
iOS 18 entitlement handling; migrated from BiometricPrompt Compat to Jetpack stable |
Passkeys in 2025: The Shift That Changes Everything
Passkeys are now synced: iCloud Keychain on iOS and Google Password Manager on Android. A passkey enrolled on a user's iPhone is available on their iPad without any action from your app. That cross-device availability changes the WebView story — a credential may have been created on a different device, so your relying party server needs to handle roaming authenticators, not just local ones.
The FIDO Alliance's 2025 "Third-Party Passkey Provider" spec also opens the door for enterprise credential managers (1Password, Dashlane, etc.) to serve as passkey providers. For enterprise apps, this means passkeys are no longer tied to Apple or Google's cloud — but it adds a new testing surface.
When to use passkeys over traditional biometric session tokens: if your minimum OS target is iOS 16 / Android 9 and your users have cloud keychain sync enabled, passkeys are the more phishing-resistant and lower-maintenance choice long-term. If you're supporting older OS versions or offline/MDM environments, a native bridge is still the right call.
Step-by-Step Implementation Guide
Implementing biometric login in a WebView app requires: a native handler on each platform, a JavaScript bridge to connect them, and server-side challenge-response verification. Skipping the third step is the most common security mistake.
Step 0: Pre-Implementation Checklist
Before writing any bridge code:
- [ ] Confirm your WebView type (WKWebView, Android WebView, RN WebView, Flutter WebView, Capacitor)
- [ ] Run the
window.PublicKeyCredentialdiagnostic snippet above in your WebView's console - [ ] Decide on authentication architecture: session token, passkey, or hybrid
- [ ] Register a Relying Party ID and configure a server-side WebAuthn library
- [ ] Check Associated Domains (iOS) / Digital Asset Links (Android) requirements if you're targeting passkeys
- [ ] Plan fallback for devices without biometric enrollment
Step 1: iOS Implementation (WKWebView + LocalAuthentication Bridge)
Set up the JavaScript message handler in your WKWebView configuration:
// AppDelegate or WKWebView setup
let contentController = WKUserContentController()
contentController.add(self, name: "biometricAuth")
let config = WKWebViewConfiguration()
config.userContentController = contentController
webView = WKWebView(frame: .zero, configuration: config)
Handle the incoming message in native Swift:
extension ViewController: WKScriptMessageHandler {
func userContentController(
_ userContentController: WKUserContentController,
didReceive message: WKScriptMessage
) {
guard message.name == "biometricAuth",
let body = message.body as? [String: Any],
let action = body["action"] as? String else { return }
if action == "authenticate" {
authenticateWithBiometrics { success, error in
DispatchQueue.main.async {
let result = success ? "true" : "false"
self.webView.evaluateJavaScript(
"window.onBiometricResult(\(result))",
completionHandler: nil
)
}
}
}
}
}
Trigger Face ID / Touch ID with LAContext:
func authenticateWithBiometrics(completion: @escaping (Bool, Error?) -> Void) {
let context = LAContext()
var error: NSError?
guard context.canEvaluatePolicy(.deviceOwnerAuthenticationWithBiometrics, error: &error) else {
completion(false, error)
return
}
context.evaluatePolicy(
.deviceOwnerAuthenticationWithBiometrics,
localizedReason: "Log in to your account"
) { success, authError in
completion(success, authError)
}
}
Call it from your web page JavaScript:
function requestBiometricLogin() {
window.webkit.messageHandlers.biometricAuth.postMessage({
action: "authenticate",
userId: currentUser.id
});
}
window.onBiometricResult = function(success) {
if (success) {
// Exchange for a signed session token from your server
exchangeTokenWithServer();
} else {
showFallbackLogin();
}
};
Security note: Never use the boolean bridge result alone as proof of authentication. Always exchange it server-side for a signed, time-limited session token using a challenge-response pattern.
Step 2: Android Implementation (BiometricPrompt + JavascriptInterface)
Add the Jetpack dependencies to build.gradle:
implementation "androidx.biometric:biometric:1.2.0"
implementation "androidx.credentials:credentials:1.3.0" // 2025 Credential Manager
Register a JavascriptInterface and wire up BiometricPrompt:
class BiometricBridge(private val activity: AppCompatActivity) {
@JavascriptInterface
fun authenticate() {
activity.runOnUiThread {
showBiometricPrompt()
}
}
private fun showBiometricPrompt() {
val executor = ContextCompat.getMainExecutor(activity)
val callback = object : BiometricPrompt.AuthenticationCallback() {
override fun onAuthenticationSucceeded(result: BiometricPrompt.AuthenticationResult) {
activity.webView.evaluateJavascript(
"window.onBiometricResult(true)", null
)
}
override fun onAuthenticationFailed() {
activity.webView.evaluateJavascript(
"window.onBiometricResult(false)", null
)
}
}
val prompt = BiometricPrompt(activity, executor, callback)
val promptInfo = BiometricPrompt.PromptInfo.Builder()
.setTitle("Biometric Login")
.setSubtitle("Confirm your identity")
.setNegativeButtonText("Use Password")
.build()
prompt.authenticate(promptInfo)
}
}
// In your Activity/Fragment setup:
webView.addJavascriptInterface(BiometricBridge(this), "AndroidBiometric")
webView.settings.javaScriptEnabled = true
JavaScript side (Android):
function requestBiometricLogin() {
if (window.AndroidBiometric) {
AndroidBiometric.authenticate();
}
}
For apps targeting Android 14+ with WebView 120+, the CredentialManager.getCredential() path via GetPublicKeyCredentialOption may eliminate the need for a custom JavascriptInterface — check the Android Credential Manager docs for your target API level.
Step 3: React Native WebView Implementation
Package selection (2025–2026):
| Package | iOS | Android | Passkeys | Expo | RN New Arch |
|---|---|---|---|---|---|
expo-local-authentication 14.x |
✅ | ✅ | ❌ | ✅ | ✅ |
react-native-biometrics 3.x |
✅ | ✅ | ❌ | ⚠️ | ✅ |
react-native-passkeys 1.x |
✅ | ✅ | ✅ | ⚠️ | ✅ |
Inject the bridge and handle messages:
import { WebView } from 'react-native-webview';
import * as LocalAuthentication from 'expo-local-authentication';
const injectedJS = `
window.requestBiometric = function() {
window.ReactNativeWebView.postMessage(JSON.stringify({ type: 'BIOMETRIC_AUTH' }));
};
true;
`;
const handleMessage = async (event) => {
const data = JSON.parse(event.nativeEvent.data);
if (data.type === 'BIOMETRIC_AUTH') {
const result = await LocalAuthentication.authenticateAsync({
promptMessage: 'Log in to your account',
fallbackLabel: 'Use Passcode',
});
webViewRef.current.injectJavaScript(
`window.onBiometricResult(${result.success}); true;`
);
}
};
return (
<WebView
ref={webViewRef}
source={{ uri: 'https://yourapp.com/login' }}
injectedJavaScript={injectedJS}
onMessage={handleMessage}
javaScriptEnabled
/>
);
Step 4: Flutter WebView Implementation
Use local_auth: ^2.3.0 paired with webview_flutter: ^4.x. Register a JavascriptChannel on the Flutter side, call LocalAuthentication().authenticate() in the channel handler, then push the result back with controller.runJavascript().
Required manifest additions: USE_BIOMETRIC and USE_FINGERPRINT permissions on Android; NSFaceIDUsageDescription in Info.plist on iOS.
Note the 2025 async change: isDeviceSupported() is now a Future<bool> — await it before rendering your biometric UI. Also watch for conflicts with Android's predictive back gesture in local_auth 2.3.x.
Step 5: Capacitor Implementation
Install @capacitor-community/biometric-auth v5.x. Unlike the manual bridges above, Capacitor's plugin exposes the check and authentication calls directly to your web layer:
import { BiometricAuth } from '@capacitor-community/biometric-auth';
const { isAvailable } = await BiometricAuth.checkBiometry();
if (isAvailable) {
await BiometricAuth.authenticate({ reason: 'Log in to your account' });
}
No custom native bridge code required. For iOS 18 targets, apply the WKWebView entitlement workaround documented in the v5 plugin changelog.
Step 6: Server-Side Verification (Don't Skip This)
The native biometric check tells you the user proved ownership of their device. It does not tell your server that. A modified app binary or injected JavaScript can return true to the WebView without any biometric event occurring.
The correct flow:
- WebView requests a random nonce from your server (expires in ~60 seconds)
- Native layer signs the nonce with a private key stored in the Secure Enclave (iOS) or Android Keystore
- Signed assertion passes back through the bridge to the WebView
- WebView posts the signed assertion to your server
- Server verifies the signature against the registered public key and issues a JWT
Recommended server-side libraries: SimpleWebAuthn (Node.js), py_webauthn (Python), webauthn4j (Java).
Step 7: Testing Your Biometric Integration
- iOS Simulator: Hardware → Biometrics → Touch ID or Face ID → Matching / Non-matching touch
- Android Emulator:
adb -e emu finger touch <fingerprint-id> - Automated testing: Detox for React Native; biometric mock hooks for integration tests
Edge cases to cover before shipping: device without biometrics enrolled, biometric lockout (5 consecutive failures), app sent to background mid-prompt, screen rotation during the prompt dialog.
Passkeys in WebView: The 2025 Best Practice
For apps targeting iOS 18+ and Android 14+, passkeys via Associated Domains / Digital Asset Links are now a viable first-class option — no custom JS bridge required in many scenarios. For everything else, the native bridge approach above remains the pragmatic path.
When Passkeys Are Preferable to a Custom Bridge
- Users on iOS 18+ / Android 14+ with iCloud Keychain or Google Password Manager enabled
- B2C apps with high account-takeover risk (passkeys are phishing-resistant by construction)
- Apps where cross-device login UX matters — a passkey enrolled on one device is immediately available on another
When a Native Bridge Is Still the Right Call
- Enterprise apps on MDM-managed devices where cloud keychain sync is disabled by policy
- Offline-first apps that can't rely on cloud credential availability
- Apps supporting Android below 9 or iOS below 16 (passkey minimum OS baseline)
- Compliance environments requiring on-device-only credential storage
To enable passkeys in a WebView context: on iOS, configure an apple-app-site-association file and add the Associated Domains entitlement; on Android, host an assetlinks.json file with the targetPackageName matching your app. Both files live on your relying party domain and must be reachable over HTTPS.
Common Mistakes Developers Make
The most common biometric WebView implementation mistakes are: trusting the boolean result, missing platform guards, and ignoring lockout states. Each one is fixable in under an hour.
Mistake 1: Trusting the Boolean Result Without Server Verification
The bridge returns true. You grant access. An attacker with a rooted device or a modified binary also returns true. Never check result.success === true and immediately open a session. Always use the server challenge-response flow described in Step 6.
Mistake 2: Not Handling Biometric Lockout
iOS locks biometrics after 5 consecutive failures (LAError.biometryLockout). Android does the same (ERROR_LOCKOUT, with a permanent lockout at ERROR_LOCKOUT_PERMANENT). If you don't catch these, users hit a dead end with no way forward. Catch both error codes explicitly and redirect to your PIN or password fallback with a clear message.
Mistake 3: Missing NSFaceIDUsageDescription in Info.plist
Your app will crash on the first biometric call without this key. Add NSFaceIDUsageDescription to Info.plist with a user-facing reason string. One key covers both Face ID and Touch ID on modern iOS — you don't need a separate TouchIDUsageDescription.
Mistake 4: Running BiometricPrompt Off the Main Thread (Android)
Calling BiometricPrompt.authenticate() outside the main thread causes silent crashes or UI freezes. Use withContext(Dispatchers.Main) in Kotlin, or call it inside activity.runOnUiThread {}. Create the BiometricPrompt instance in onCreate(), not inside the button handler.
Mistake 5: Using Deprecated FingerprintManager
FingerprintManager was deprecated in API 28 and breaks on Android 12+ across many OEMs. If you're seeing it in legacy code, migrate to BiometricPrompt from androidx.biometric:biometric.
Mistake 6: Not Checking Availability Before Rendering the UI
Showing a "Login with Face ID" button to a user on a device with no biometrics enrolled leads to runtime errors and a confusing UX. Gate the button on a capability check:
// After bridge is initialized
window.checkBiometricAvailability().then(available => {
document.getElementById('biometric-btn').style.display = available ? 'block' : 'none';
});
Corresponding native checks: LAContext.canEvaluatePolicy() on iOS, BiometricManager.canAuthenticate(BIOMETRIC_STRONG) on Android.
Mistake 7: Hardcoding window.webkit.messageHandlers Without a Platform Guard
window.webkit.messageHandlers.biometricAuth.postMessage(...) throws a TypeError on Android and in desktop browsers. Route by environment:
function requestBiometricLogin() {
if (window.webkit?.messageHandlers?.biometricAuth) {
// iOS WKWebView
window.webkit.messageHandlers.biometricAuth.postMessage({ action: 'authenticate' });
} else if (window.AndroidBiometric) {
// Android WebView
AndroidBiometric.authenticate();
} else if (window.ReactNativeWebView) {
// React Native
window.ReactNativeWebView.postMessage(JSON.stringify({ type: 'BIOMETRIC_AUTH' }));
} else {
// Fallback: standard WebAuthn or password
fallbackToPasswordLogin();
}
}
Security Architecture: Protecting Biometric Sessions End-to-End
Biometric authentication doesn't prove a fingerprint matched — it proves the user had access to a private key that the OS only unlocks after a biometric match. That distinction matters for how you architect the whole session.
What the Secure Enclave and Android Keystore Actually Do
On iOS, the Secure Enclave holds a non-exportable hardware-bound private key. Face ID success is the gating condition the Enclave checks before allowing any signing operation. The biometric template never leaves the device. On Android, the Keystore enforces setUserAuthenticationRequired(true) at the hardware level — the key can't be used until the system logs a successful biometric event.
Attestation goes one step further: Android Key Attestation and Apple DeviceCheck let your server verify that a key was generated on genuine hardware, not emulated. For high-value operations, implement attestation server-side.
Challenge-Response Authentication Flow
Here's the full, correct flow from tap to JWT:
- User taps "Login with Face ID" in the WebView
- WebView → Bridge: request a challenge (random nonce) from the server
- Server issues a nonce expiring in ~60 seconds
- Bridge → Native: sign the nonce using the Secure Enclave / Keystore-backed private key (biometric auth unlocks this operation)
- Native → Bridge → WebView: signed assertion returned
- WebView → Server: POST the signed assertion
- Server verifies the signature against the registered public key → issues a JWT
- WebView stores the JWT in memory (not
localStorage) for the session
Token Storage Best Practices
localStorage inside a WebView is accessible to the native app layer at any time via evaluateJavaScript. It's also exposed to XSS — and WebViews don't have the cross-origin isolation protections that browsers do.
Use this hierarchy instead:
- In-memory JS variable — shortest-lived; lost on page reload
- HTTP-only cookie — medium-lived; set via
WKHTTPCookieStore(iOS) orCookieManager(Android); inaccessible to JavaScript - Native Keychain / EncryptedSharedPreferences — long-lived refresh tokens; the right place for anything that should survive an app restart
Rotate tokens on every biometric re-authentication.
Platform Comparison: iOS vs. Android vs. Cross-Platform Frameworks
iOS WKWebView and Android WebView now both offer partial native WebAuthn support on their latest OS versions, but cross-platform frameworks still require plugins.
Feature Support Matrix (2025–2026)
| Feature | iOS WKWebView | Android WebView | RN WebView | Flutter WebView | Capacitor |
|---|---|---|---|---|---|
| Native WebAuthn (no bridge) | iOS 18+ partial | Android 14+ partial | ❌ (use plugin) | ❌ (use plugin) | ✅ via plugin |
| Face ID / Touch ID via bridge | ✅ | N/A | ✅ | ✅ | ✅ |
| Android Biometric via bridge | N/A | ✅ | ✅ | ✅ | ✅ |
| Passkeys (synced) | iOS 18+ ✅ | Android 14+ ✅ | ✅ (plugin) | ✅ (plugin) | ✅ (plugin) |
| Secure Enclave / Keystore binding | ✅ | ✅ | ✅ | ✅ | ✅ |
| Biometric lockout fallback | ✅ | ✅ | ✅ | ✅ | ✅ |
| Challenge-response architecture | ✅ | ✅ | ✅ | ✅ | ✅ |
Minimum OS / SDK Requirements
| Feature | iOS | Android API | React Native | Flutter | Capacitor |
|---|---|---|---|---|---|
| Touch ID / Fingerprint | 8.0 | 23 (6.0) | 0.60+ | 3.0+ | 5.x+ |
| Face ID | 11.0 | N/A | 0.60+ | 3.0+ | 5.x+ |
| Android Face Unlock | N/A | 28 (9.0) | 0.60+ | 3.0+ | 5.x+ |
| Passkeys | 16.0 | 28 (9.0) | 0.71+ | 3.10+ | 6.x+ |
| WKWebView native WebAuthn | 18.0+* | N/A | — | — | — |
| Android WebView WebAuthn | N/A | 34 (14.0)* | — | — | — |
*Partial support — conditions apply. See the 2025–2026 updates section.
Accessibility & Fallback UX for Biometric Login
Always provide a fallback. Biometrics are unavailable or unenrolled on a meaningful portion of devices, and they lock out after repeated failures.
Required Fallback Hierarchy
- Device biometric (Face ID / Touch ID / Fingerprint)
- Device PIN / Pattern / Password — via
deviceOwnerAuthenticationpolicy, which doesn't require biometric enrollment - App-level PIN — 4–6 digit with rate limiting on your server
- Email magic link or OTP — fully out-of-band, server-driven
UX Copy for Each Biometric State
| State | Recommended UI Copy |
|---|---|
| Biometrics available | "Log in with Face ID" / "Log in with fingerprint" |
| Biometrics not enrolled | "Set up Face ID for faster login" (link to device settings) |
| Biometric lockout | "Too many attempts — use your passcode to continue" |
| Biometrics not supported | Hide biometric option entirely; show password |
| Auth failed (1–4 attempts) | "Didn't recognize you — try again or use your password" |
Performance Considerations: Minimizing Biometric Prompt Latency
A slow biometric prompt is almost always caused by initializing the prompt on the user's tap rather than at app start. Move initialization earlier.
Reducing Time-to-Prompt
- Pre-initialize
LAContext/BiometricPromptat app start, not at tap time. On Android, create theBiometricPromptinstance inonCreate(). - Avoid blocking the DOM during the bridge call. Trigger it from a
requestAnimationFramecallback so the tap visual feedback completes first. - On iOS,
LAContextis lightweight — creating it early has negligible overhead.
Caching Authentication State
Re-prompting every minute is overkill for most apps. The industry standard is a 5–15 minute validity window for sensitive operations.
- iOS:
LAContext.touchIDAuthenticationAllowableReuseDurationlets you define a window (in seconds) during which a successful auth can be reused without re-prompting. - Android:
setUserAuthenticationValidityDurationSecondsinKeyGenParameterSpecdoes the same for Keystore-backed operations.
For long-lived sessions, separate the "silent re-auth" path (re-use cached auth within the validity window) from the "re-prompt" path (validity expired or sensitivity escalated).
Frequently Asked Questions
Can I use WebAuthn directly in WKWebView without a native bridge?
As of iOS 18, WKWebView has limited WebAuthn support when the app uses Associated Domains and the com.apple.developer.web-browser-engine.webcontent entitlement, but for most apps and iOS versions below 18, a native LocalAuthentication bridge is still required. "Limited" means registration and assertion work for first-party domains in the standard flow — not for third-party or iframe contexts. The bridge is safer and more widely compatible for now.
Why does my Android biometric prompt close immediately when the app is backgrounded?
Android's BiometricPrompt is lifecycle-aware and automatically cancels when the host Activity is stopped. If the user switches apps mid-prompt, the dialog disappears. Handle onAuthenticationError with error code ERROR_CANCELED (code 5) specifically and consider re-triggering the prompt in onResume() if the user returns.
How do I prevent my JavascriptInterface from being exploited by injected JavaScript?
Restrict which origins can interact with your @JavascriptInterface methods and validate all incoming data on the native side. Override WebViewClient.onPageStarted to check that the loaded URL matches your allowlist before the interface is active. Treat every field in message.body as untrusted user input — sanitize it in the native handler. Set WebView.setWebContentsDebuggingEnabled(false) in production builds. Avoid attaching a JavascriptInterface to any WebView that loads third-party content.
Does biometric authentication work in Expo managed workflow?
Yes — expo-local-authentication works in Expo managed workflow for biometric prompts on both iOS and Android. For passkeys or a fully custom WebView bridge, you'll need bare workflow or an Expo config plugin. Expo SDK 52 (2025) is compatible with expo-local-authentication 14.x and RN New Architecture. Link the authenticateAsync result back to your WebView via the onMessage handler.
Can biometric login work offline in a WebView app?
The biometric prompt itself always works offline — it's a local device operation. If your authentication requires a server challenge-response, connectivity is needed to complete the full flow. For offline-capable apps, cache an encrypted session token in native Keychain (iOS) or EncryptedSharedPreferences (Android), use biometric auth to decrypt and re-activate it locally, and define a time-based or usage-count-based expiry strategy for offline sessions.
How do I handle users who haven't enrolled any biometrics?
Always call the availability check API before presenting biometric UI and offer a graceful fallback when biometrics are unavailable. On iOS, LAContext.canEvaluatePolicy(.deviceOwnerAuthenticationWithBiometrics, error: &error) returns false with LAError.biometryNotEnrolled. On Android, BiometricManager.canAuthenticate(BIOMETRIC_STRONG) returns BIOMETRIC_ERROR_NONE_ENROLLED — from which you can deep-link the user to enrollment settings via BiometricManager.Authenticators. Expose availability as a bridge call (window.checkBiometricAvailability()) and hide the biometric button entirely when enrollment check fails.
Is it safe to store the biometric auth token in localStorage inside the WebView?
No. localStorage inside a WebView is accessible to the native app layer via evaluateJavaScript at any time, and XSS risk is higher than in a browser because WebViews lack cross-origin isolation. Store sensitive tokens in native Keychain (iOS) or EncryptedSharedPreferences (Android). For session cookies, use WKHTTPCookieStore (iOS) or CookieManager (Android) to set HTTP-only, Secure cookies that JavaScript cannot read.
Key Takeaways
- WebView ≠ browser. You almost always need a native bridge. Don't assume WebAuthn works out of the box.
- Platform-check first. Run
window.PublicKeyCredentialin your WebView before architecting anything — your WebView may support more than you expect on iOS 18+ / Android 14+. - Never trust the boolean. Always back biometric success with a server-side challenge-response and signed JWT.
- Passkeys are the forward path. If your minimum OS target is iOS 16 / Android 9, invest in passkeys now alongside your bridge implementation.
- Handle failures gracefully. Biometric lockout, unenrolled devices, and background interruptions are daily-driver scenarios, not edge cases.
- Store tokens natively. Keychain and EncryptedSharedPreferences, not
localStorage. - Audit annually. iOS 18 and Android 14+ changed the native WebView story. Platform release notes are part of your maintenance cycle.
Recommended Implementation Path
Is your minimum iOS target 18+ AND Android target 14+?
→ YES: Evaluate passkey-first with Associated Domains / Digital Asset Links
→ NO: Implement native bridge (Swift / Kotlin) + passkey as progressive enhancement
Are you using a cross-platform framework?
→ React Native: expo-local-authentication + ReactNativeWebView onMessage bridge
→ Flutter: local_auth + webview_flutter JavascriptChannel
→ Capacitor: @capacitor-community/biometric-auth (simplest path)
Do you need offline biometric login?
→ YES: Implement encrypted token caching in Keychain / Keystore
→ NO: Standard challenge-response with server JWT is sufficient
Ready to implement biometric login in your WebView app?
[Insert your CTA here — e.g., "Download our starter kit", "Book a free architecture review", or "Read our WebAuthn server setup guide".]