Skip to main content

Android WebAuthn / Biometric Integration Guide

Complete guide for integrating ZewstID's WebAuthn/Passkeys with Android Biometric (fingerprint, face, iris) in your Android app.

Prerequisites

  • Android SDK 28+ (Android 9.0+)
  • AndroidX libraries
  • Kotlin 1.8+
  • HTTPS backend (WebAuthn requires secure context)
  • Digital Asset Links configured

Project Setup

1. Add Dependencies

In your

app/build.gradle
:

dependencies { // AndroidX Biometric implementation 'androidx.biometric:biometric:1.2.0-alpha05' // Credentials API (for WebAuthn) implementation 'androidx.credentials:credentials:1.3.0-alpha01' implementation 'androidx.credentials:credentials-play-services-auth:1.3.0-alpha01' // Networking implementation 'com.squareup.retrofit2:retrofit:2.9.0' implementation 'com.squareup.retrofit2:converter-gson:2.9.0' implementation 'com.squareup.okhttp3:logging-interceptor:4.11.0' // Coroutines implementation 'org.jetbrains.kotlinx:kotlinx-coroutines-android:1.7.3' // Security implementation 'androidx.security:security-crypto:1.1.0-alpha06' }

2. Configure AndroidManifest.xml

<manifest xmlns:android="http://schemas.android.com/apk/res/android"> <uses-permission android:name="android.permission.INTERNET" /> <uses-permission android:name="android.permission.USE_BIOMETRIC" /> <application android:usesCleartextTraffic="false" android:networkSecurityConfig="@xml/network_security_config"> <!-- Your activities --> <!-- Digital Asset Links for WebAuthn --> <meta-data android:name="asset_statements" android:resource="@string/asset_statements" /> </application> </manifest>

Create

res/values/strings.xml
:

<resources> <string name="asset_statements" translatable="false"> [{ \"relation\": [\"delegate_permission/common.handle_all_urls\", \"delegate_permission/common.get_login_creds\"], \"target\": { \"namespace\": \"web\", \"site\": \"https://api.zewstid.com\" } }] </string> </resources>

Host this file at:

https://api.zewstid.com/.well-known/assetlinks.json
:

[{ "relation": ["delegate_permission/common.handle_all_urls", "delegate_permission/common.get_login_creds"], "target": { "namespace": "android_app", "package_name": "com.yourapp.package", "sha256_cert_fingerprints": [ "YOUR_APP_SHA256_FINGERPRINT" ] } }]

4. Network Security Configuration

Create

res/xml/network_security_config.xml
:

<?xml version="1.0" encoding="utf-8"?> <network-security-config> <base-config cleartextTrafficPermitted="false"> <trust-anchors> <certificates src="system" /> </trust-anchors> </base-config> </network-security-config>

Implementation

ZewstBiometricManager.kt

Complete production-ready implementation:

package com.yourapp.auth import android.content.Context import android.util.Base64 import androidx.biometric.BiometricManager import androidx.biometric.BiometricPrompt import androidx.core.content.ContextCompat import androidx.credentials.* import androidx.credentials.exceptions.* import androidx.fragment.app.FragmentActivity import com.google.gson.Gson import com.google.gson.annotations.SerializedName import kotlinx.coroutines.Dispatchers import kotlinx.coroutines.withContext import okhttp3.MediaType.Companion.toMediaType import okhttp3.OkHttpClient import okhttp3.Request import okhttp3.RequestBody.Companion.toRequestBody import okhttp3.logging.HttpLoggingInterceptor import org.json.JSONObject import java.security.SecureRandom /** * Manages WebAuthn/Passkey authentication with ZewstID */ class ZewstBiometricManager( private val context: Context, private val apiBaseUrl: String, private val clientId: String, private val rpId: String = "zewst.com" ) { private val credentialManager = CredentialManager.create(context) private val gson = Gson() private val prefs = context.getSharedPreferences("zewst_auth", Context.MODE_PRIVATE) private val httpClient = OkHttpClient.Builder() .addInterceptor(HttpLoggingInterceptor().apply { level = HttpLoggingInterceptor.Level.BODY }) .build() // MARK: - Registration /** * Register biometric authentication for the current user * Call this after user has authenticated with password * * @param activity Current activity (required for biometric prompt) * @param idToken Valid ID token from password login (use ID token, not access token) * @param credentialName Name for this credential (e.g., device model) * @return Credential information */ suspend fun registerBiometric( activity: FragmentActivity, idToken: String, credentialName: String = android.os.Build.MODEL ): BiometricCredential { return withContext(Dispatchers.IO) { // Step 1: Check if biometric is available if (!isBiometricAvailable()) { throw BiometricException("Biometric authentication not available") } // Step 2: Get registration options from server val options = getRegistrationOptions(idToken) // Step 3: Create credential val credential = createPublicKeyCredential(activity, options) // Step 4: Verify registration with server val verifiedCredential = verifyRegistration( credential = credential, challenge = options.challenge, credentialName = credentialName, idToken = idToken ) // Step 5: Store credential info locally storeCredentialInfo(verifiedCredential) verifiedCredential } } private suspend fun getRegistrationOptions(idToken: String): RegistrationOptions { val request = Request.Builder() .url("$apiBaseUrl/api/v1/auth/webauthn/register/begin") .post("{}".toRequestBody("application/json".toMediaType())) .addHeader("Authorization", "Bearer $idToken") .build() val response = httpClient.newCall(request).execute() if (!response.isSuccessful) { throw BiometricException("Failed to get registration options: ${response.code}") } val jsonResponse = response.body?.string() ?: throw BiometricException("Empty response") val wrapper = gson.fromJson(jsonResponse, RegistrationOptionsResponse::class.java) return wrapper.options } private suspend fun createPublicKeyCredential( activity: FragmentActivity, options: RegistrationOptions ): CreatePublicKeyCredentialResponse { // Convert options to JSON val requestJson = JSONObject().apply { put("challenge", options.challenge) put("rp", JSONObject().apply { put("name", options.rp.name) put("id", options.rp.id) }) put("user", JSONObject().apply { put("id", options.user.id) put("name", options.user.name) put("displayName", options.user.displayName) }) put("pubKeyCredParams", options.pubKeyCredParams.map { param -> JSONObject().apply { put("type", param.type) put("alg", param.alg) } }) put("timeout", options.timeout) put("attestation", options.attestation) put("authenticatorSelection", JSONObject().apply { put("authenticatorAttachment", "platform") put("requireResidentKey", true) put("residentKey", "preferred") put("userVerification", "preferred") }) }.toString() val createRequest = CreatePublicKeyCredentialRequest(requestJson) return try { credentialManager.createCredential( context = activity, request = createRequest ) as CreatePublicKeyCredentialResponse } catch (e: CreateCredentialException) { when (e) { is CreateCredentialCancellationException -> { throw BiometricException("User cancelled registration") } is CreateCredentialInterruptedException -> { throw BiometricException("Registration was interrupted") } is CreateCredentialProviderConfigurationException -> { throw BiometricException("Provider configuration error") } is CreateCredentialUnknownException -> { throw BiometricException("Unknown error: ${e.message}") } else -> { throw BiometricException("Failed to create credential: ${e.message}") } } } } private suspend fun verifyRegistration( credential: CreatePublicKeyCredentialResponse, challenge: String, credentialName: String, idToken: String ): BiometricCredential { val registrationJson = credential.registrationResponseJson val requestBody = JSONObject().apply { put("response", JSONObject(registrationJson)) put("challenge", challenge) put("credentialName", credentialName) }.toString() val request = Request.Builder() .url("$apiBaseUrl/api/v1/auth/webauthn/register/finish") .post(requestBody.toRequestBody("application/json".toMediaType())) .addHeader("Authorization", "Bearer $idToken") .build() val response = httpClient.newCall(request).execute() if (!response.isSuccessful) { throw BiometricException("Failed to verify registration: ${response.code}") } val jsonResponse = response.body?.string() ?: throw BiometricException("Empty response") val wrapper = gson.fromJson(jsonResponse, RegistrationVerificationResponse::class.java) return wrapper.credential } // MARK: - Authentication /** * Authenticate user with biometric * * @param activity Current activity (required for biometric prompt) * @param email Optional email to filter credentials * @return Authentication tokens */ suspend fun authenticateWithBiometric( activity: FragmentActivity, email: String? = null ): AuthTokens { return withContext(Dispatchers.IO) { // Step 1: Check if biometric is available if (!isBiometricAvailable()) { throw BiometricException("Biometric authentication not available") } // Step 2: Get authentication options val options = getAuthenticationOptions(email) // Step 3: Get credential assertion val assertion = getPublicKeyCredentialAssertion(activity, options) // Step 4: Verify assertion and get tokens val tokens = verifyAuthentication(assertion, options.challenge) // Step 5: Store tokens securely storeTokens(tokens) tokens } } private suspend fun getAuthenticationOptions(email: String?): AuthenticationOptions { val requestBody = if (email != null) { JSONObject().apply { put("email", email) }.toString() } else { "{}" } val request = Request.Builder() .url("$apiBaseUrl/api/v1/auth/webauthn/authenticate/begin") .post(requestBody.toRequestBody("application/json".toMediaType())) .build() val response = httpClient.newCall(request).execute() if (!response.isSuccessful) { throw BiometricException("Failed to get authentication options: ${response.code}") } val jsonResponse = response.body?.string() ?: throw BiometricException("Empty response") val wrapper = gson.fromJson(jsonResponse, AuthenticationOptionsResponse::class.java) return wrapper.options } private suspend fun getPublicKeyCredentialAssertion( activity: FragmentActivity, options: AuthenticationOptions ): GetCredentialResponse { // Convert options to JSON val requestJson = JSONObject().apply { put("challenge", options.challenge) put("timeout", options.timeout) put("rpId", options.rpId) put("userVerification", options.userVerification) }.toString() val getRequest = GetPublicKeyCredentialOption(requestJson) val credentialRequest = GetCredentialRequest(listOf(getRequest)) return try { credentialManager.getCredential( context = activity, request = credentialRequest ) } catch (e: GetCredentialException) { when (e) { is GetCredentialCancellationException -> { throw BiometricException("User cancelled authentication") } is GetCredentialInterruptedException -> { throw BiometricException("Authentication was interrupted") } is NoCredentialException -> { throw BiometricException("No credentials found. Please register first.") } is GetCredentialUnknownException -> { throw BiometricException("Unknown error: ${e.message}") } else -> { throw BiometricException("Failed to get credential: ${e.message}") } } } } private suspend fun verifyAuthentication( assertion: GetCredentialResponse, challenge: String ): AuthTokens { val publicKeyCredential = assertion.credential as PublicKeyCredential val authenticationJson = publicKeyCredential.authenticationResponseJson val requestBody = JSONObject().apply { put("response", JSONObject(authenticationJson)) put("challenge", challenge) put("clientId", clientId) }.toString() val request = Request.Builder() .url("$apiBaseUrl/api/v1/auth/webauthn/authenticate/finish") .post(requestBody.toRequestBody("application/json".toMediaType())) .build() val response = httpClient.newCall(request).execute() if (!response.isSuccessful) { throw BiometricException("Failed to verify authentication: ${response.code}") } val jsonResponse = response.body?.string() ?: throw BiometricException("Empty response") return gson.fromJson(jsonResponse, AuthTokens::class.java) } // MARK: - Credential Management /** * Check if biometric authentication is available */ fun isBiometricAvailable(): Boolean { val biometricManager = BiometricManager.from(context) return when (biometricManager.canAuthenticate(BiometricManager.Authenticators.BIOMETRIC_STRONG)) { BiometricManager.BIOMETRIC_SUCCESS -> true else -> false } } /** * Check if user has registered credentials */ fun hasRegisteredCredentials(): Boolean { return prefs.getBoolean("has_webauthn_credential", false) } private fun storeCredentialInfo(credential: BiometricCredential) { prefs.edit().apply { putBoolean("has_webauthn_credential", true) putString("webauthn_credential_id", credential.id) putString("webauthn_credential_name", credential.name) apply() } } // MARK: - Token Management private fun storeTokens(tokens: AuthTokens) { // Use EncryptedSharedPreferences for secure storage val masterKey = androidx.security.crypto.MasterKey.Builder(context) .setKeyScheme(androidx.security.crypto.MasterKey.KeyScheme.AES256_GCM) .build() val encryptedPrefs = androidx.security.crypto.EncryptedSharedPreferences.create( context, "zewst_secure_prefs", masterKey, androidx.security.crypto.EncryptedSharedPreferences.PrefKeyEncryptionScheme.AES256_SIV, androidx.security.crypto.EncryptedSharedPreferences.PrefValueEncryptionScheme.AES256_GCM ) encryptedPrefs.edit().apply { putString("id_token", tokens.idToken) putString("access_token", tokens.accessToken) putString("refresh_token", tokens.refreshToken) putLong("expires_at", System.currentTimeMillis() + (tokens.expiresIn * 1000)) apply() } } fun getAccessToken(): String? { val masterKey = androidx.security.crypto.MasterKey.Builder(context) .setKeyScheme(androidx.security.crypto.MasterKey.KeyScheme.AES256_GCM) .build() val encryptedPrefs = androidx.security.crypto.EncryptedSharedPreferences.create( context, "zewst_secure_prefs", masterKey, androidx.security.crypto.EncryptedSharedPreferences.PrefKeyEncryptionScheme.AES256_SIV, androidx.security.crypto.EncryptedSharedPreferences.PrefValueEncryptionScheme.AES256_GCM ) val expiresAt = encryptedPrefs.getLong("expires_at", 0) if (System.currentTimeMillis() > expiresAt) { return null // Token expired } return encryptedPrefs.getString("access_token", null) } } // MARK: - Models data class RegistrationOptionsResponse( val options: RegistrationOptions ) data class RegistrationOptions( val challenge: String, val user: UserInfo, val rp: RelyingPartyInfo, val pubKeyCredParams: List<PublicKeyCredentialParameters>, val timeout: Long, val attestation: String ) { data class UserInfo( val id: String, val name: String, val displayName: String ) data class RelyingPartyInfo( val name: String, val id: String ) data class PublicKeyCredentialParameters( val type: String, val alg: Int ) } data class RegistrationVerificationResponse( val success: Boolean, val credential: BiometricCredential, val message: String ) data class BiometricCredential( val id: String, val name: String, val createdAt: String, val deviceType: String ) data class AuthenticationOptionsResponse( val options: AuthenticationOptions ) data class AuthenticationOptions( val challenge: String, val timeout: Long, val rpId: String, val userVerification: String ) data class AuthTokens( @SerializedName("id_token") val idToken: String, @SerializedName("access_token") val accessToken: String, @SerializedName("refresh_token") val refreshToken: String, @SerializedName("expires_in") val expiresIn: Long, @SerializedName("token_type") val tokenType: String ) class BiometricException(message: String) : Exception(message)

Usage Examples

Example 1: Password + Biometric Registration

import androidx.compose.material3.* import androidx.compose.runtime.* import androidx.compose.ui.platform.LocalContext import androidx.fragment.app.FragmentActivity import kotlinx.coroutines.launch @Composable fun LoginScreen() { val context = LocalContext.current val activity = context as FragmentActivity val scope = rememberCoroutineScope() var email by remember { mutableStateOf("") } var password by remember { mutableStateOf("") } var showBiometricSetup by remember { mutableStateOf(false) } var idToken by remember { mutableStateOf<String?>(null) } var errorMessage by remember { mutableStateOf<String?>(null) } val biometricManager = remember { ZewstBiometricManager( context = context, apiBaseUrl = "https://api.zewstid.com", clientId = "your-client-id" ) } Column(modifier = Modifier.padding(16.dp)) { TextField( value = email, onValueChange = { email = it }, label = { Text("Email") } ) Spacer(modifier = Modifier.height(16.dp)) TextField( value = password, onValueChange = { password = it }, label = { Text("Password") }, visualTransformation = PasswordVisualTransformation() ) Spacer(modifier = Modifier.height(24.dp)) Button( onClick = { scope.launch { try { // Step 1: Authenticate with password val tokens = authenticateWithPassword(email, password) idToken = tokens.idToken // Step 2: Prompt to set up biometric if (!biometricManager.hasRegisteredCredentials()) { showBiometricSetup = true } } catch (e: Exception) { errorMessage = e.message } } } ) { Text("Sign In") } errorMessage?.let { Text(text = it, color = MaterialTheme.colorScheme.error) } } if (showBiometricSetup && idToken != null) { BiometricSetupDialog( biometricManager = biometricManager, idToken = idToken!!, activity = activity, onDismiss = { showBiometricSetup = false } ) } } @Composable fun BiometricSetupDialog( biometricManager: ZewstBiometricManager, idToken: String, activity: FragmentActivity, onDismiss: () -> Unit ) { val scope = rememberCoroutineScope() var isRegistering by remember { mutableStateOf(false) } var errorMessage by remember { mutableStateOf<String?>(null) } AlertDialog( onDismissRequest = onDismiss, title = { Text("Set Up Biometric") }, text = { Column { Text("Sign in faster and more securely with biometric authentication") errorMessage?.let { Spacer(modifier = Modifier.height(8.dp)) Text(text = it, color = MaterialTheme.colorScheme.error) } } }, confirmButton = { Button( onClick = { isRegistering = true scope.launch { try { val credential = biometricManager.registerBiometric( activity = activity, idToken = idToken, credentialName = android.os.Build.MODEL ) println("✅ Biometric registered: ${credential.name}") onDismiss() } catch (e: Exception) { errorMessage = e.message isRegistering = false } } }, enabled = !isRegistering ) { if (isRegistering) { CircularProgressIndicator(modifier = Modifier.size(16.dp)) } else { Text("Enable Biometric") } } }, dismissButton = { TextButton(onClick = onDismiss) { Text("Skip") } } ) }

Example 2: Passwordless Biometric Login

@Composable fun BiometricLoginScreen() { val context = LocalContext.current val activity = context as FragmentActivity val scope = rememberCoroutineScope() var isAuthenticating by remember { mutableStateOf(false) } var errorMessage by remember { mutableStateOf<String?>(null) } val biometricManager = remember { ZewstBiometricManager( context = context, apiBaseUrl = "https://api.zewstid.com", clientId = "your-client-id" ) } Column( modifier = Modifier .fillMaxSize() .padding(32.dp), horizontalAlignment = Alignment.CenterHorizontally, verticalArrangement = Arrangement.Center ) { Icon( imageVector = Icons.Default.Fingerprint, contentDescription = "Biometric", modifier = Modifier.size(100.dp), tint = MaterialTheme.colorScheme.primary ) Spacer(modifier = Modifier.height(32.dp)) Text( text = "Sign in with Biometric", style = MaterialTheme.typography.headlineMedium ) Spacer(modifier = Modifier.height(16.dp)) errorMessage?.let { Text( text = it, color = MaterialTheme.colorScheme.error, style = MaterialTheme.typography.bodySmall ) Spacer(modifier = Modifier.height(16.dp)) } Button( onClick = { isAuthenticating = true errorMessage = null scope.launch { try { val tokens = biometricManager.authenticateWithBiometric(activity) println("✅ Authenticated with biometric") println("ID Token: ${tokens.idToken}") println("Access Token: ${tokens.accessToken}") // Navigate to main app } catch (e: Exception) { errorMessage = e.message isAuthenticating = false } } }, enabled = !isAuthenticating ) { if (isAuthenticating) { CircularProgressIndicator(modifier = Modifier.size(16.dp)) } else { Icon(imageVector = Icons.Default.Fingerprint, contentDescription = null) Spacer(modifier = Modifier.width(8.dp)) Text("Sign In with Biometric") } } Spacer(modifier = Modifier.height(16.dp)) TextButton(onClick = { /* Navigate to password login */ }) { Text("Use Password Instead") } } }

Example 3: Conditional MFA

class AuthenticationService( private val context: Context, private val biometricManager: ZewstBiometricManager ) { suspend fun signIn( activity: FragmentActivity, email: String, password: String ): AuthTokens { // Step 1: Authenticate with password val passwordTokens = authenticateWithPassword(email, password) // Step 2: Check if MFA is required if (shouldRequireMFA(passwordTokens)) { println("⚠️ High-risk login detected - requiring biometric") // Step 3: Require biometric verification return biometricManager.authenticateWithBiometric(activity, email) } return passwordTokens } private fun shouldRequireMFA(tokens: AuthTokens): Boolean { // Implement your risk assessment logic // Examples: // - New device // - Unusual location // - Suspicious activity // - Sensitive operation requested return false // Placeholder } private suspend fun authenticateWithPassword( email: String, password: String ): AuthTokens { // Your OAuth password flow implementation TODO("Implement password authentication") } }

Testing

1. Emulator Testing

WebAuthn is supported in Android Emulator (API 28+):

  1. Open emulator
  2. Go to Settings → Security → Fingerprint
  3. Add fingerprint
  4. Use "Touch Sensor" button in emulator controls

2. Test User

val testEmail = "test@example.com" val testPassword = "TestPassword123!"

3. Debug Logging

Add logging interceptor:

private val httpClient = OkHttpClient.Builder() .addInterceptor(HttpLoggingInterceptor().apply { level = HttpLoggingInterceptor.Level.BODY }) .build()

Error Handling

ErrorCauseSolution
BiometricManager.BIOMETRIC_ERROR_NO_HARDWARE
No biometric hardwareShow message: "Biometric not supported"
BiometricManager.BIOMETRIC_ERROR_NONE_ENROLLED
No biometric enrolledPrompt user to enroll in Settings
GetCredentialCancellationException
User cancelledShow message: "Authentication cancelled"
NoCredentialException
No credentials registeredPrompt user to register
HTTP 400Invalid challengeRe-request challenge
HTTP 401Expired tokenRefresh access token

Security Best Practices

  1. Use EncryptedSharedPreferences for token storage
  2. Validate server responses - Check status codes
  3. Handle network failures gracefully
  4. Don't cache challenges - Request new challenge each time
  5. Log security events - Track registration/authentication
  6. Provide fallback - Always allow password login
  7. Test on real devices - Some emulators have limitations
  8. Configure Digital Asset Links properly

Production Checklist

  • AndroidManifest.xml configured with permissions
  • Digital Asset Links configured
  • assetlinks.json hosted at server
  • HTTPS endpoints only
  • EncryptedSharedPreferences for tokens
  • Error handling implemented
  • Fallback to password implemented
  • User feedback for biometric prompts
  • Testing on physical devices
  • Analytics/logging added

Next Steps

Was this page helpful?

Let us know how we can improve our documentation