Creating a Passkey Client for Android
With the backend in place, let’s shift to Android and implement the client side flow using Kotlin and Android Jetpack’s Credential Manager API. This article will guide you through integrating biometric prompts, handling passkey creation and sign in, and communicating with the server using WebAuthn compatible data. All while delivering a seamless user experience.
Overview
This project will create a client Android application that talks to a restful server to handle Passkey authentication. You will have to test on a physical Android device. CredentialManager will not work on an Android emulator.
Setting up the Project
Create a new project in Android Studio. I'm using Kotlin and Jetpack Compose for the UI. Jetpack Compose isn't required, but it made the job a lot easier for me. Also, I'm using retrofit for networking and Moshi for serializing JSON to and from the server endpoints.
Creating a Simple Login Screen
Once you've created the bones of a project, we'll start with a basic login screen UI. I'm going to hand the reins off to Jetpack Compose from MainActivity, use a view model, and I'm also keeping an instance of my PasskeyManager class in the activity. More on that later.
class MainActivity : ComponentActivity() {
private val viewModel: MainViewModel by viewModels()
private val passkeyManager = PasskeyManager(this)
override fun onCreate(savedInstanceState: Bundle?) {
super.onCreate(savedInstanceState)
enableEdgeToEdge()
setContent {
PasskeysAndroidTheme {
PasskeyApp(viewModel)
}
}
setupViewModel()
}
private fun setupViewModel() {
}
}
@Preview(showBackground = true)
@Composable
fun MainPreview() {
PasskeysAndroidTheme {
PasskeyApp(viewModel = MainViewModel())
}
}
Later, we're going to use setupViewModel to implement a couple lamdbas in the view model for creating and validating passkeys. PasskeyManager holds an instance of Android's CredentialManager class that handles passkeys and it needs a context. It's a no-no to pass an actvity context into a view model, so I'm working around that this way. If you're a seasoned Android developer, you've probably got a solution in your back pocket that's better than mine. I've also got a preview here to show the UI for the only screen in this app.
Here's the start of the MainViewModel class. I'm also including a LoginStatus enum here. For this tutorial, I'm using this to keep track of where we are in the flow and to help with debugging. I'm also showing a snackbar for key events along the way.
enum class LoginStatus(var displayString: String) {
SIGN_UP_IN_PROGRESS("Sign up in progress..."),
SIGN_UP_SUCCESS("Signed up successfully!"),
SIGN_UP_FAILED("Sign up failed"),
LOGIN_IN_PROGRESS("Logging in..."),
LOGIN_SUCCESS("Logged in!"),
LOGIN_FAILED("Login failed"),
LOGGED_OUT("Logged out")
}
class MainViewModel : ViewModel() {
var loginStatus = mutableStateOf(LoginStatus.LOGGED_OUT)
var username = mutableStateOf("")
var snackbarHostState = SnackbarHostState()
var createPasskey: (userId: String, payload: String) -> Unit = { _, _ -> }
var validateCredential: (username: String, request: GetCredentialRequest) -> Unit = { _, _ -> }
suspend fun showSnackbar(status: LoginStatus, errorMessage: String? = null) {
val message = status.displayString + (errorMessage ?: "")
snackbarHostState.showSnackbar(message)
}
private val moshi: Moshi = Moshi.Builder()
.add(KotlinJsonAdapterFactory())
.build()
}
The PasskeyApp composable holds the Scaffold for the Jetpack Compose UI. It's pretty simple:
@Composable
fun PasskeyApp(
viewModel: MainViewModel
) {
val coroutineScope = rememberCoroutineScope()
Scaffold(
snackbarHost = {
SnackbarHost(hostState = viewModel.snackbarHostState)
},
modifier = Modifier.fillMaxSize()
) { innerPadding ->
LoginView(
viewModel = viewModel,
onLogin = {
coroutineScope.launch {
viewModel.onLoginButtonTapped()
}
},
onSignUp = {
coroutineScope.launch {
viewModel.onSignUpButtonTapped()
}
},
modifier = Modifier.padding(innerPadding)
)
}
}
Setting up Networking
Like most Android projects, I'm using Retrofit for handling network requests. I have set up a pretty basic interface:
interface ApiInterface {
companion object {
private val BASE_URL = "https://942e-2600-1700-3e41-88b0-147b-69e-2c32-ee33.ngrok-free.app"
fun retrofit(): Retrofit {
val moshi = Moshi.Builder().build()
return Retrofit.Builder()
.baseUrl(BASE_URL)
.addConverterFactory(MoshiConverterFactory.create(moshi))
.client(okHttpClient())
.build()
}
private fun okHttpClient(): OkHttpClient {
val loggingInterceptor = HttpLoggingInterceptor()
loggingInterceptor.level = HttpLoggingInterceptor.Level.BODY
return OkHttpClient.Builder()
.addInterceptor(DefaultHeaderInterceptor())
.addInterceptor(loggingInterceptor)
.build()
}
}
}
class DefaultHeaderInterceptor() : Interceptor {
override fun intercept(chain: Interceptor.Chain): okhttp3.Response {
var request = chain.request()
.newBuilder()
.addHeader("Accept", "application/json")
.addHeader("Content-Type", "application/json")
.build()
return chain.proceed(request)
}
}
Quick rundown on what's happening above, I'm building an instance of Retrofit and setting it up with Moshi. I'm also setting up an OkHttpClient with logging, as well as an interceptor that will add a set of headers to all of my requests. I'm also using a BASE_URL value that will change each time my ngrok address changes. For this tutorial, you're going to want something that allows you to connect to your local Passkey server from a physical Android device. ngrok can help with that.
A Quick Note about ngrok
ngrok is a tool that creates an http or https endpoint and routes the traffic to your local machine. You're going to need this to make calls to your local server during testing. You can download ngrok for free here. Once you get it set up, all you have to do is run ./ngrok http 8080 in Terminal and you'll be able to connect to your local server with the URL it provides. The https version of the URL will be your relying party, and the base URL for connecting to your backend server. Don't forget to update those values if you turn ngrok off and back on again, because the URL will change.
Setting up a Few Kotlin Extensions
Since we'll do lots of base 64 url safe encoding, here's an extension I'm using in several places:
fun String.base64UrlEncoded(): String {
return Base64.encodeToString(
this.encodeToByteArray(),
Base64.NO_WRAP or Base64.URL_SAFE or Base64.NO_PADDING
)
}
I've also got these Moshi functions for converting to and from JSON:
// [Moshi] extension to transform an object to json
inline fun <reified T> Moshi.objectToJson(data: T): String =
adapter(T::class.java).toJson(data)
// [Moshi] extension to transform json to an object
inline fun <reified T> Moshi.jsonToObject(json: String): T? =
adapter(T::class.java).fromJson(json)
PasskeyManager and CredentialManager
This project uses Android's CredentialManager for handling the Passkey flow. CredentialManager requires an activity context. I'm trying to have some separation of concerns and not put all of this Passkey handling code in the activity, so I created a PasskeyManager class to encapsulate it in one place.
Here's a basic outline of the PasskeyManager class:
class PasskeyManager(val activity: ComponentActivity) {
var credentialManager = CredentialManager.create(context = activity)
private val moshi: Moshi = Moshi.Builder()
.add(KotlinJsonAdapterFactory())
.build()
@RequiresApi(Build.VERSION_CODES.UPSIDE_DOWN_CAKE)
suspend fun createPasskey(
userId: String,
requestJson: String,
preferImmediatelyAvailableCredentials: Boolean
): Boolean {
return false
}
}
private suspend fun handlePasskeyRegistrationResult(
userId: String,
result: CreateCredentialResponse
): Boolean {
return false
}
suspend fun validateCredential(
username: String,
request: GetCredentialRequest
): Boolean {
return false
}
private suspend fun handleSignInResult(
username: String,
result: GetCredentialResponse
): Boolean {
return false
}
Creating a Passkey
Before we can log in with a Passkey on our Android device, we've got to create one. If you're building on my vapor Passkey server article, you can enter any username or email address or whatever into the TextField and tap the Sign Up button. That's going to call onSignUpButtonTapped in our view model. Here's the implementation for that. The first thing that has to happen is making a call to our Passkey server.
I'll show the whole LoginAPI now, but for now we're focused on the signUp function. It makes a call to the /signup endpoint and passes a username param from our text field:
interface LoginApi {
@GET("/signup")
suspend fun signUp(
@Query("username") username: String
) : Response<ChallengeResponse>
@POST("/makeCredential")
suspend fun makeCredential(
@Query("userId") userId: String,
@Body postBody: MakeCredentialPostBody
) : Response<Unit>
@GET("/authenticate")
suspend fun login(
@Query("username") username: String
) : Response<PublicKeyCredentialRequestOptions>
@POST("/authenticate")
suspend fun authenticate(
@Query("username") username: String,
@Body postBody: PublicKeyCredentialBody
) : Response<Unit>
companion object {
fun retrofit(): LoginApi {
return ApiInterface.retrofit().create(LoginApi::class.java)
}
}
}
Here's the implementation of onSignUpButtonTapped in the view model:
suspend fun onSignUpButtonTapped() {
showSnackbar(LoginStatus.SIGN_UP_IN_PROGRESS)
// 1. Make a call to the sign up endpoint
val response = LoginApi.retrofit().signUp(username.value)
if (response.isSuccessful) {
Log.i("", "got a response: $response")
response.body()?.let { challengeResponse ->
// 2. Base64 url encode the userId and challenge
val userIdEncoded = challengeResponse.userId.base64UrlEncoded()
val challengeEncoded = challengeResponse.challenge.challenge.base64UrlEncoded()
val user = challengeResponse.challenge.user
user.id = userIdEncoded
// 3. Build a PublicKeyCredentialCreationOptions and convert it to JSON
val creationOptions = PublicKeyCredentialCreationOptions(
rp = challengeResponse.challenge.relyingParty,
user = user,
challenge = challengeEncoded,
pubKeyCredParams = challengeResponse.challenge.params,
timeout = challengeResponse.challenge.timeout,
attestation = challengeResponse.challenge.attestation,
excludeCredentials = listOf()
)
val json = moshi.objectToJson(creationOptions)
Log.i("", "creationOptions json: $json")
// 4. Ready to create our Passkey using Android's CredentialManager
createPasskey(
challengeResponse.userId,
moshi.objectToJson(creationOptions)
)
} ?: run {
showSnackbar(LoginStatus.SIGN_UP_FAILED)
}
} else {
val errorMessage = response.errorBody().toString()
Log.e("", "got an error: $errorMessage")
showSnackbar(
status = LoginStatus.SIGN_UP_FAILED,
errorMessage = errorMessage
)
}
}
Here's a breakdown of what's happening above. It looks like a lot, but it's not that bad:
As mentioned above, we make a call to our server's sign up endpoint. This will create our new user in the system (if it doesn't already exist), and send back a challenge.
We need to Base64 url encode the challenge. This is a common theme throughout a Passkey implementation. Everything gets Base64 url encoded.
We build up a
PublicKeyCredentialCreationOptions, which holds the details that are needed to create our Passkey. We're also using Moshi to convert this object to JSON.The JSON object, along with our user's id, gets sent off to the
createPasskeyfunction, which will useCredentialManagerto build a Passkey for Android.
Here's the implementation for PublicKeyCredentialCreationOptions:
@JsonClass(generateAdapter = true)
data class PublicKeyCredentialCreationOptions(
val rp: RelyingParty,
val user: AuthUser,
val challenge: String,
val pubKeyCredParams: List<PublicKeyCredParams>,
val timeout: Int,
val attestation: String,
val excludeCredentials: List<String>
)
Earlier, we created a setupViewModel function in MainActivity. It's time to add our implementation for createPasskey there, so the view model can call it:
private fun setupViewModel() {
viewModel.createPasskey = { userId, payload ->
lifecycleScope.launch {
val result = passkeyManager.createPasskey(
userId = userId,
requestJson = payload,
preferImmediatelyAvailableCredentials = true
)
if (result) {
viewModel.showSnackbar(LoginStatus.SIGN_UP_SUCCESS)
} else {
viewModel.showSnackbar(LoginStatus.SIGN_UP_FAILED)
}
}
}
}
We're really only doing this here because CredentialManager needs a context. Trying to have some kind of encapsulation, I'm using a PasskeyManager class to hold an instance of CredentialManager and the code for managing Passkey registration and authentication. Here's our createPasskey implementation:
@RequiresApi(Build.VERSION_CODES.UPSIDE_DOWN_CAKE)
suspend fun createPasskey(
userId: String,
requestJson: String,
preferImmediatelyAvailableCredentials: Boolean
): Boolean {
Log.i("", "ready to create public key credentials with: $requestJson")
val createPublicKeyCredentialRequest = CreatePublicKeyCredentialRequest(
// Contains the request in JSON format. Uses the standard WebAuthn web JSON spec.
requestJson = requestJson,
// Defines whether you prefer to use only immediately available credentials,
// not hybrid credentials, to fulfill this request. This value is false by default.
preferImmediatelyAvailableCredentials = preferImmediatelyAvailableCredentials,
)
// Execute CreateCredentialRequest asynchronously to register credentials
// for a user account. Handle success and failure cases with the result and
// exceptions, respectively.
try {
val result = credentialManager.createCredential(
context = activity,
request = createPublicKeyCredentialRequest
)
return handlePasskeyRegistrationResult(userId, result)
} catch (e : CreateCredentialException) {
Log.e("", "failed to create a credential: $e")
return false
}
}
At this point, the Android OS will bring up a dialog to create a Passkey and gives back a result. Let's handle that:
private suspend fun handlePasskeyRegistrationResult(
userId: String,
result: CreateCredentialResponse
): Boolean {
(result as? CreatePublicKeyCredentialResponse)?.let {
// 1. Use Moshi to decode the registration response
val createCredentialObject = moshi.jsonToObject<CreateCredential>(it.registrationResponseJson)
Log.i("", "here's the registrationResponseJson: ${it.registrationResponseJson}")
// 2. Generate a JSON payload to send the Passkey to the server
createCredentialObject?.payload()?.let { payload ->
Log.i("", "credentialPayload: $payload")
val response = LoginApi.retrofit().makeCredential(userId = userId, postBody = payload)
if (response.isSuccessful) {
Log.i("", "got makeCredential response: $response")
return true
} else {
val error = response.errorBody().toString()
Log.e("", "got an error: $error")
return false
}
} ?: run {
return false
}
} ?: run {
return false
}
}
Buckle Up, It's About to Get Weird
I'm going to share the CreateCredential class and its payload function next. The authenticator data that we need to pass along contains a byte array with binary data and a number of flags. Worlds collide here, as Android is not quite following the rules on the format of these flags, and the Swift library is not flexible in handling that. So we've got to tap into the authenticator data before it gets sent and make sure everything lines up. In my experience, the Android-generated data says it's including extension data (the ED flag), but doesn't actually include any. Android also includes attestation data, but doesn't set the AT flag. The WebAuthn library isn’t having that, and the whole thing fails.
I don't think I would have figured this part out without ChatGPT, and probably would have given up. I'll share what I came up with that works, and feel free to use, edit, and dig into it on your own:
@JsonClass(generateAdapter = true)
data class CreateCredential(
val id: String,
val rawId: String,
val authenticatorAttachment: String,
val type: String,
val response: CreateCredentialInnerResponse
) {
fun payload(): MakeCredentialPostBody {
val cborMap = decodedCBOR
var authenticatorData = cborMap["authData"].GetByteString().clone()
val flags = authenticatorData[32].toInt() and 0xFF
Log.d("WebAuthn", "Original Flags Byte: 0x${flags.toString(16).uppercase()} (Binary: ${flags.toString(2).padStart(8, '0')})")
Log.d("WebAuthn", "Original Authenticator Data Length: ${authenticatorData.size}")
// ✅ Always clear ED (`0x80`)
authenticatorData[32] = (authenticatorData[32].toInt() and 0x7F).toByte() // `0x7F` = `01111111`
// ✅ If extra bytes exist (authenticatorData > 37), set `AT = true`
if (authenticatorData.size > 37) {
authenticatorData[32] = (authenticatorData[32].toInt() or 0x40).toByte() // `0x40` = `01000000`
}
val fixedFlags = authenticatorData[32].toInt() and 0xFF
Log.d("WebAuthn", "Fixed Flags Byte: 0x${fixedFlags.toString(16).uppercase()} (Binary: ${fixedFlags.toString(2).padStart(8, '0')})")
Log.d("WebAuthn", "Final Authenticator Data Length: ${authenticatorData.size}")
val modifiedCborMap = CBORObject.NewMap()
modifiedCborMap["fmt"] = cborMap["fmt"]
modifiedCborMap["attStmt"] = cborMap["attStmt"]
modifiedCborMap["authData"] = CBORObject.FromObject(authenticatorData)
val modifiedCborBytes = modifiedCborMap.EncodeToBytes()
val newAttestationObject = Base64.encodeToString(modifiedCborBytes, Base64.NO_WRAP or Base64.URL_SAFE or Base64.NO_PADDING)
val credentialPayload = MakeCredentialPostBody(
id = id,
rawId = rawId,
authenticatorAttachment = authenticatorAttachment,
type = type,
response = MakeCredentialResponseParam(
attestationObject = newAttestationObject,
clientDataJSON = response.clientDataJSON
)
)
return credentialPayload
}
}
@JsonClass(generateAdapter = true)
data class CreateCredentialInnerResponse(
val attestationObject: String,
val clientDataJSON: String
)
@JsonClass(generateAdapter = true)
data class MakeCredentialPostBody(
val id: String,
val rawId: String,
val authenticatorAttachment: String,
val type: String,
val response: MakeCredentialResponseParam
)
@JsonClass(generateAdapter = true)
data class MakeCredentialResponseParam(
val attestationObject: String,
val clientDataJSON: String
)
This code basically takes the credential data apart and puts it back together as JSON, for sending to the Passkey server.
Back to PasskeyManager
Back in PasskeyManager's handlePasskeyRegistrationResult, we have to send this payload to our server's /makeCredential endpoint. If that succeeds, we've successfully created a Passkey, saved it on the server, and it can be used to log in.
Signing In
When the user taps the Log in button, we'll trigger onLoginButtonTapped in the view model:
suspend fun onLoginButtonTapped() {
showSnackbar(LoginStatus.LOGIN_IN_PROGRESS)
val username = username.value
// 1. Call our login endpoint
val response = LoginApi.retrofit().login(username)
if (response.isSuccessful) {
Log.i("", "got an auth response: $response")
response.body()?.let { credentialOptions ->
// 2. Parse the response and prepare for sending to CredentialManager
val responseJson = moshi.objectToJson(credentialOptions)
val jsonObject = JSONObject(responseJson)
val challenge = jsonObject.getString("challenge")
jsonObject.put("challenge", challenge.base64UrlEncoded())
val newRequestJson = jsonObject.toString()
val getPublicKeyCredentialOption = GetPublicKeyCredentialOption(
requestJson = newRequestJson
)
val getPasswordOption = GetPasswordOption()
val credentialRequest = GetCredentialRequest(
listOf(getPasswordOption, getPublicKeyCredentialOption)
)
// 3. Use CredentialManager to validate the credentials
validateCredential(username, credentialRequest)
} ?: run {
Log.e("", "something went wrong at login")
showSnackbar(status = LoginStatus.LOGIN_FAILED)
}
} else {
val errorMessage = response.errorBody().toString()
val responseCode = response.code()
val displayMessage = "got a $responseCode, error: $errorMessage"
Log.e("", "got a $responseCode, error: $errorMessage")
showSnackbar(
status = LoginStatus.LOGIN_FAILED,
errorMessage = displayMessage
)
}
}
Here's what this code does:
Makes a call to the server's
/authenticateendpoint, sending the username. The server looks up to see if that user exists, and sends back a challenge.We need to Base64 url encode the challenge, and put that JSON into a
GetPublicKeyCredentialOptionclass. This is a class Android provides. We then stick that into aGetCredentialRequest.And off to
CredentialManagerit goes for validation.
Back in MainActivity, we need to provide our implementation of validateCredential to the view model. This gives us a path to interact with PasskeyManager. So add this to setupViewModels:
private fun setupViewModel() {
...
viewModel.validateCredential = { username, request ->
lifecycleScope.launch {
val result = passkeyManager.validateCredential(
username = username,
request = request
)
if (result) {
viewModel.showSnackbar(LoginStatus.LOGIN_SUCCESS)
} else {
viewModel.showSnackbar(LoginStatus.LOGIN_FAILED)
}
}
}
}
Now we can implement PasskeyManager's validateCredential function:
suspend fun validateCredential(
username: String,
request: GetCredentialRequest
): Boolean {
try {
val result = credentialManager.getCredential(
context = activity,
request = request
)
return handleSignInResult(username, result)
} catch (e: GetCredentialException) {
Log.e("", "got a credential exception: $e")
return false
}
}
private suspend fun handleSignInResult(
username: String,
result: GetCredentialResponse
): Boolean {
val credential = result.credential
when (credential) {
is PublicKeyCredential -> {
val responseJson = credential.authenticationResponseJson
Log.i("", "got responseJson: $responseJson")
val createCredentialObject = moshi.jsonToObject<PublicKeyCredentialBody>(responseJson)?.let {
val response = LoginApi.retrofit().authenticate(username, it.payload)
if (response.isSuccessful) {
Log.i("", "authenticate response: $response")
return true
} else {
val error = response.errorBody().toString()
Log.e("", "got an error: $error")
// TODO: could throw an error?
return false
}
}
}
is PasswordCredential -> {
val username = credential.id
val password = credential.password
Log.i("", "got a PasswordCredential")
return false
}
else -> {
// Catch any unrecognized credential type here.
Log.e("", "Unexpected type of credential")
return false
}
}
return false
}
Above, we make our call to CredentialManager's getCredential, and then handle the result. We really only care about the public key credential for the purposes of this project since we're concerned with Passkeys. From the user's perspective, they'll receieve a system prompt for biometrics, and if everything goes well you'll get a success and credential data.
We build up a POST payload from the credential, and send that to our /authenticate endpoint that receives a POST body.
Here's the implementation for PublicKeyCredentialBody:
@JsonClass(generateAdapter = true)
data class PublicKeyCredentialBody(
val id: String,
val rawId: String,
val type: String,
val authenticatorAttachment: String,
val response: PublicKeyCredentialResponse
) {
val payload: PublicKeyCredentialBody
get() {
val something = response.decodedAuthenticatorData
Log.i("", "what actually gets sent: ${response.authenticatorData.unsignedBase64Encoded()}")
return PublicKeyCredentialBody(
id = id,
rawId = rawId,
type = type,
authenticatorAttachment = authenticatorAttachment,
response = PublicKeyCredentialResponse(
clientDataJSON = response.clientDataJSON,
authenticatorData = response.authenticatorData,
userHandle = response.userHandle.base64UrlEncoded(),
signature = response.signature
)
)
}
}
@JsonClass(generateAdapter = true)
data class PublicKeyCredentialResponse(
val clientDataJSON: String,
val authenticatorData: String,
val userHandle: String,
val signature: String
)
You should get a 200 success back from the server. If so, the user has successfully logged in. In a real application, you'll want to give the user a token at this point. They'll include that token in subsequent calls to your backend endpoints. I'll leave the rest of that exercise up to you.
Conclusion
If you've made it this far, congrats! You're ready to fire up ngrok in a terminal, use the generated url as your relying party on your vapor server and Android application, and test it out!
If you got here first, here are the other 2 articles in this series: