Blog Infos
Author
Published
Topics
, , , ,
Published

Image credit: author

 

The internet would be a much more secure place for its users if passkeys replaced passwords. They are phishing-proof, guess-proof and come with built-in two-factor authentication (2FA). They are also more privacy-preserving than OAuth methods like Sign In With Google, because they don’t attach your account to a centralised identity.

But they aren’t that easy for us developers to implement.

The aim of this article, therefore, is to show you how to implement passkeys in your Android app all the way from back- to front-end.

That back-end part is important, as that’s what I found missing in existing documentation. It’s too easy to build an Android front-end that doesn’t works against any back-end, and likewise a back-end that doesn’t work with Android.

Here are the repos we’ll use in this article:
Android apphttps://github.com/tdcolvin/PasskeyAuthDemoAndroid
Back-end server (NodeJS)https://github.com/tdcolvin/PasskeyAuthDemoServer

The full-stack application we’re going to build

This is the application we’re going to build. We’ll use Android’s Jetpack Credential Manager to sign ourselves up — that is, “register” a passkey — and then sign in (“authenticate”) with that passkey. And at each step we’ll see what the server needs to respond with:

Demonstration of the app we’ll build. Has a sign in button and a sign up button. Demo shows sign up flow then sign in flow, with fingerprint needed for each.

 

But before we can build this, we need a back end to sign up to. I promised a full-stack tutorial, right? So:

An Android-compatible passkey server

You can do this the easy way, or you can give yourself full control:

  • Easy: Use my implementation at auth.tomcolvin.co.uk which works for every part of this tutorial (but is deliberately insecure so not for production use!)
  • Full control: Or, you can run your own server locally on your computer, which gives you the benefit of being able to tinker with the back end and see it working. To do that, install NodeJS then clone this repo: https://github.com/tdcolvin/PasskeyAuthDemoServer.
    Then run npm i and npm run start. More details are in the readme of that repo.

Either way, you’ll be talking to a NodeJS server which uses SimpleWebAuthn to do the complex cryptographic work. On top of that I’ve added the relevant bits to make it compatible with Android’s needs. We’ll see what those are later.

Now that we’ve got the server working, let’s create an Android app to talk to it.

The Android app using Jetpack Credential Manager

If you’re creating a new project for tinkering, your life will be made easier if you give it the ID com.tdcolvin.passkeyauthdemo, because then you can use the above back ends without alteration. But don’t worry if not!

To add the relevant libraries, add the following lines to your app/build.gradle.kts and gradle/lib.versions.toml files:

// Core Jetpack Credential Manager library
implementation(libs.androidx.credentials)
// Needed for credentials support from play services, for devices running Android 13 and below.
implementation(libs.androidx.credentials.play.services.auth)
// We're going to need to do some HTTP work...
implementation(libs.okhttp)
[versions]
...
credentials = "1.5.0"
okhttp = "4.12.0"
[libraries]
...
androidx-credentials = { module = "androidx.credentials:credentials", version.ref = "credentials" }
androidx-credentials-play-services-auth = { module = "androidx.credentials:credentials-play-services-auth", version.ref = "credentials" }
okhttp = { module = "com.squareup.okhttp3:okhttp", version.ref = "okhttp" }

The libraries we’re adding are:

  • Jetpack Credential Manager, which does all the complex cryptography stuff around passkeys, and manages secure storage of passkeys
  • OkHttp, my chosen HTTP library (there are others! For tutorial purposes OkHttp is useful for its simplicity)

Right, now that we’ve got an Android app with the relevant libraries, let’s tell the server about it.

Authorising your app: easy mode

Android will refuse to talk to a passkey auth server which isn’t expecting it. And a passkey auth server will refuse to talk to an app it’s not expecting.

If you just want to make this work with the ready-made back-end I posted above (either auth.tomcolvin.co.uk or the self-host NodeJS back-end), then:

  1. Set your app’s ID to com.tdcolvin.passkeyauthdemo
  2. Sign it with this key (password aaaaaa).

That gives it a recognised ID and fingerprint. And it means you can skip the next bit. But don’t use this key for production apps! (Obviously. It’s been posted online 😂).

Otherwise, let’s go hardcore.

Authorising your app: hard mode

But, if you’re integrating passkeys into your own app with an existing ID/key, then you’ll need to tell the server about its ID and fingerprint.

To make things difficult, the Android app has to be specified in two different places in two different ways. Which is nuts. The server needs to know about your app:

  1. In a publicly available file which the server provides (assetlinks.json)
  2. In the server’s configuration (the expected origin).

Note in both places we identify the app using its SHA-256 fingerprint, which is slightly unusual in the Android world (SHA-1 being more common).

The assetlinks.json file

The publicly available file has to be served at /.well-known/assetlinks.json. In the demo project, you can find it in the public directory. Its format is as follows:

Edit package_name and sha256_cert_fingerprints to match your app. Note that the SHA-256 fingerprint has colon-separators. (How to find your app’s SHA-256 fingerprint.)

The expected origin

In the demo project’s /src/simplewebauthn/index.ts file, we have:

export const expectedOrigin = [
`https://${rpID}`,
"android:apk-key-hash:H8aaJx3lOZCaxVnsZU5__ALkVjXJALA11rtegEE0Ldc", // signed using the keystore in the app folder
];
view raw index.ts hosted with ❤ by GitHub

What’s that weird format after android:apk-key-hash:!?!

It turns out it’s the raw bytes of the SHA-256 hash, converted to base64url format. You can use this utility to do that, but make sure you select Base64url as the Base64 variant:

Screenshot of the utility for converting hex bytes to base64urlCryptii pipeline for converting raw hex bytes to base64url (credit: author)

Edit that line, then you’re good to go.

OK, now that we’ve got an Android app set up and its ID is authorised, let’s create a new passkey in the front end.

Registering a user

Looking ahead, here’s an overview of how passkeys are created. You will need to keep this process in mind because without it the code is going to look pretty strange.

You create a passkey like this:

A summary of the interactions: Android to Server: User requests to create a passkey for their account. Server to Android: Server provides the necessary details (like challenge, domain) for passkey generation. Android to Server: Android sends the details of the newly created passkey (public key) back to the server for registration.

 

Those steps are:

  1. The client asks for information necessary to create a passkey for their account
  2. The server responds with a small piece of JSON which contains, among other things, the user’s unique ID and some cryptographic details
  3. The client creates the passkey for the user and sends it to the server
Signing up a brand new user

Note that the above assumes that the user already exists on the system. That’s really important. If the user is brand new, they need to create an account first. From a UI perspective, you can make it look like it’s all happening in one, but actually you need to create an account first and assign a passkey to it after.

Getting the registration options

The client asks the server for /generate-registration-options, which contains the information it needs to create a passkey.

Note: /generate-registration-options is what I’ve called it in my back-end implementation. But note that there’s no particular naming convention defined, so other back end implementations will probably call it something else. (This fact confused me during research because the back end documentation talked about different URLs than the front end documentation did.)

Here’s the code to get that, using OkHttp:

// Get the registration options from the auth server
suspend fun getPasskeyRegisterRequestJson(username: String): String
= withContext(Dispatchers.IO) {
val url = HttpUrl.Builder()
.scheme("https")
.host("auth.tomcolvin.co.uk")
.addPathSegment("generate-registration-options")
.addQueryParameter("username", username)
.build()
val request = Request.Builder()
.url(url)
.build()
val response = okHttpClient.newCall(request).execute()
response.body?.string() ?: throw Exception("No response body")
}
view raw MyViewModel.kt hosted with ❤ by GitHub

Those registration options look like this:

// Registration Options retrieved from server
{
  "challenge": "Fsfnx23eI0x07Dbd63QMDiqXH3WelwsVADSowKgninI",
  "rp": {
    "name": "Tom's secret site",
    "id": "auth.tomcolvin.co.uk"
  },
  "user": {
    "id": "9M0ZKXgNSaqpYkNm9vt9ay6RYwfa8Px7WZvvmcCTLC8",
    "name": "tomcolvin",
    "displayName": "tomcolvin"
  },
  "pubKeyCredParams": [
    {
      "alg": -7,
      "type": "public-key"
    },
    {
      "alg": -257,
      "type": "public-key"
    }
  ],
  "timeout": 60000,
  "attestation": "none",
  "excludeCredentials": [],
  "authenticatorSelection": {
    "residentKey": "required",
    "userVerification": "preferred",
    "requireResidentKey": true
  },
  "extensions": {
    "credProps": true
  },
  "hints": []
}

This is like an “offer” to allow you, the client, to create a passkey. It tells you which user you can create the passkey for, when you can create it, and some security constraints. Specifically:

  • challenge: this is like a one-time passcode to ensure that this request can’t be reused to create another passkey at a later date
  • rp: stands for “Relying Party” — identification details for the server
  • user: information on the user, according to the server. Importantly this contains the user ID as generated by the server
  • timeout: the passkey needs to be created within this time
  • attestation: allows the server to request that the device creating the passkey proves it’s of a certain type or has certain features. We set this to “none” because that’s better for privacy (otherwise, it potentially requires users to reveal hardware details and IDs).
  • authenticatorSelection: allows the server to specify security properties of the passkey created. Can it be used across devices? Are biometrics required?

Question: can’t the client just generate this registration options JSON itself? The answer is no: for security reasons, only the server should control whether a client is allowed to create a passkey for a given account. This registrations options response is like a “ticket” allowing the client to do so. The “barcode” on the ticket (the bit which gets scanned to allow you in) is the challenge element.

Now that we have the registration options, we have all the info we need to ask the user to create a passkey. Let’s get Jetpack Credential Manager to do that for us…

Generating the passkey

Jetpack Credential Manager takes the registration options, maybe asks the user some questions, takes their biometrics or screenlock, then builds a passkey.

We never get to see the passkey itself! It gets saved to whatever password manager we want, and obviously plays a part in the login phase, but the actual private key can never be seen by human eyes. That’s why passkeys are almost completely phishing-proof.

We start by creating an instance of CredentialManager. Assuming you’re using Compose:

val localContext = LocalContext.current
val credentialManager = remember {
CredentialManager.create(localContext)
}
view raw MainScreen.kt hosted with ❤ by GitHub

We can then feed that CredentialManager our registration options JSON, and ask it to make a passkey:

// Get the registration options.
// 'username' here is the requested username for the new account
val registerRequestJson = getPasskeyRegisterRequestJson(username)
// Create an object which holds the options needed to create the passkey
val createPublicKeyCredentialRequest = CreatePublicKeyCredentialRequest(
requestJson = registerRequestJson,
// Pop up a UI
preferImmediatelyAvailableCredentials = false
)
// Finally show the UI, and guide the user to creating the passkey
val createCredentialResponse = credentialManager.createCredential(
context = localActivity,
request = createPublicKeyCredentialRequest
)
// Make sure Credential Manager created the right kind of credential
if (createCredentialResponse !is CreatePublicKeyCredentialResponse) {
throw Exception("Incorrect response type")
}
// The response is sent back to the server
view raw MainScreen.kt hosted with ❤ by GitHub

And that results in the following popup:

Passkey demo app is registering. Google Password Manager prompts to create a passkey for “cay6jb” to sign in to PasskeyAuthDemoAndroid. It will be saved to the linked Google account and secured by the screen lock. Fingerprint or pattern can be used.

Note that this popup is actually from Google Password Manager, not Jetpack Credential Manager. If you use a different password manager (which you can, because recent Android version support third-party ones) then you’ll see a different UI.

Follow that UI through and a passkey gets created by your password manager. It has a private key which gets encrypted and secured using your biometrics or PIN, and a public key which we have to pass to the server. Let’s do that bit next…

Passing the passkey back to the server

The createCredentialResponse that we get back from credentialManager.createCredential() has a registrationResponseJson field, which we pass as-is to the server. That contains all it needs to know about the passkey we’ve just created.

This process is what the passkey standard calls verifying a credential. My server accepts this JSON via an HTTP POST to /verify-registration. (Again, no formally-defined URLs, so your implementation will differ).

Here’s the function for doing that POST:

suspend fun sendRegistrationResponse(
registrationResponseJson: String,
) = withContext(Dispatchers.IO) {
val url = HttpUrl.Builder()
.scheme("https")
.host("auth.tomcolvin.co.uk")
.addPathSegment("verify-registration")
.build()
val request = Request.Builder()
.url(url)
.post(registrationResponseJson.toRequestBody("application/json".toMediaType()))
.build()
val response = okHttpClient.newCall(request).execute()
if (response.code != 200) {
throw Exception("Registration failed: ${response.body?.string()}")
}
response.body?.string() ?: "{}"
}
view raw MyViewModel.kt hosted with ❤ by GitHub

And we use it in our sign up flow:

val responseJson = credential.authenticationResponseJson
sendAuthenticationResponse(responseJson)
view raw MainScreen.kt hosted with ❤ by GitHub

The server creates the user’s account at that point (if necessary — the user might already be logged into an existing account using another authentication method). And it adds the passkey to the account.

Even a server break-in won’t compromise your passkey

By the way, we’ve just seen another bit of passkey cleverness. That is, the data in this response JSON isn’t sufficient for someone else to figure out your passkey and use it for themselves.

Passkeys rely on public key cryptography, where you have a private and public key. The private key is needed for authentication. The public key is there to allow you to prove that you have the private key without revealing it.

Only the public key gets sent to the server. So even if a bad guy broke into the server and stole all those public keys, they still couldn’t authenticate as you. They don’t have the private key, as it never left your device.

Contrast this with passwords where there has to be a shared secret. The reason I can authenticate with my password “abc123” is because the server has stored “abc123” in some form, and compares it to my input.

Public key cryptography is magic. 🪄🎩🐇

OK, now we have an account with a passkey. How do we authenticate next time?

Authenticating with a passkey

The process starts in the same way for authentication as it did for registration:

Tom (client) asks server to auth. Server sends challenge. Tom’s device signs it. Tom sends signed challenge. Server verifies, authenticates Tom.

 

To start with, we need an “offer” from the server. This contains details of the credentials it’ll accept, and a challenge for us to sign using one of them:

suspend fun getPasskeyAuthenticationRequestJson(username: String): String = withContext(
Dispatchers.IO) {
val url = HttpUrl.Builder()
.scheme("https")
.host("auth.tomcolvin.co.uk")
.addPathSegment("generate-authentication-options")
.addQueryParameter("username", username)
.build()
val request = Request.Builder()
.url(url)
.build()
val response = okHttpClient.newCall(request).execute()
response.body?.string() ?: throw Exception("No response body")
}
view raw MyViewModel.kt hosted with ❤ by GitHub

The authentication options JSON looks like this:

{
  "rpId": "auth.tomcolvin.co.uk",
  "challenge": "nL_rWyPqXhvnyo-6_C8V7FaJITnDg6uUa2KWAlrJpEM",
  "allowCredentials": [
    {
      "id": "wsHlHo8rk1RjUpMZV4eLNw",
      "type": "public-key",
      "transports": [
        "hybrid",
        "internal"
      ]
    }
  ],
  "timeout": 60000,
  "userVerification": "preferred"
}

That challenge is all-important. In order to authenticate ourselves, we’ll have to sign it (together with some other bits) with our private key. The passkey server then knows it’s us because it has our public key to verify that signature.

And in order to get the private key, we have to ask the user for their biometrics and decrypt it out of the secure storage.

But we don’t have to worry about the complexity of any of the above! Because Credential Manager does everything we need, in just a single credentialManager.getCredential() call:

val localActivity = LocalActivity.current
...
// Get the authentication request JSON from the server, using the code above
val authenticationRequestJson = getPasskeyAuthenticationRequestJson(username)
// Build the request for Credential Manager
val getPublicKeyCredentialOption = GetPublicKeyCredentialOption(
requestJson = authenticationRequestJson
)
val signInRequest = GetCredentialRequest(listOf(getPublicKeyCredentialOption))
// Ask Credential Manager to process the sign-in request
// This will pop up a dialog asking the user for their biometrics or PIN
// (hence the need to pass in the activity)
val result = credentialManager.getCredential(
context = localActivity,
request = signInRequest
)
val credential = result.credential
// Check that the returned credential is a passkey (PublicKeyCredential)
if (credential !is PublicKeyCredential) {
throw Exception("Incorrect credential type")
}
view raw MainScreen.kt hosted with ❤ by GitHub

That code pops up the following screen:

Sign in with passkey screen, asking for fingerprint

The credential we get out of the code above has an authenticationResponse field. This is a JSON object containing the proof of our identity, and it’s what needs to go up to the server.

val responseJson = credential.authenticationResponseJson
sendAuthenticationResponse(responseJson)
view raw MainScreen.kt hosted with ❤ by GitHub
suspend fun sendAuthenticationResponse(authenticationResponseJson: String): String = withContext(
Dispatchers.IO) {
val url = HttpUrl.Builder()
.scheme("https")
.host("auth.tomcolvin.co.uk")
.addPathSegment("verify-authentication")
.build()
val request = Request.Builder()
.url(url)
.post(authenticationResponseJson.toRequestBody("application/json".toMediaType()))
.build()
val response = okHttpClient.newCall(request).execute()
response.body?.string() ?: throw Exception("No response body")
}
view raw MyViewModel.kt hosted with ❤ by GitHub

Job Offers

Job Offers

There are currently no vacancies.

OUR VIDEO RECOMMENDATION

, ,

Securing Android: Tackling Advanced Threats and Enhancing App Security

What threats are Android apps dealing with these days? In this talk, we will look at the latest security challenges and the best ways to keep your apps safe from new threats.
Watch Video

Securing Android: Tackling Advanced Threats and Enhancing App Security

Renaud Murat
Tech Lead
Zimperium

Securing Android: Tackling Advanced Threats and Enhancing App Security

Renaud Murat
Tech Lead
Zimperium

Securing Android: Tackling Advanced Threats and Enhancing App Security

Renaud Murat
Tech Lead
Zimperium

Jobs

Once that’s all done, the server should respond that you’re authenticated correctly. And that’s all there is to it!

To conclude

My aim in writing this article is just to get more apps to support passkeys. They are a win-win, being more secure than passwords, and more privacy-preserving than OAuth options. They are a little harder to implement, but I hope you’ll agree now that it’s worth the effort.

We’ve seen how to create passkeys in both the back- and front end. To do so, I introduced my passkey server repo and cloud-based demo. And we’ve seen how to use those passkeys to authenticate users.

Here are the repos we used in this article:
Android apphttps://github.com/tdcolvin/PasskeyAuthDemoAndroid
Back-end server (NodeJS)https://github.com/tdcolvin/PasskeyAuthDemoServer

I hope this has been helpful! As ever please leave questions/comments below, or contact me directly.

Hi, I’m Tom Colvin; I help build and grow app businesses. I’m a Google Developer Expert in Android with experience launching literally hundreds of apps. I’m available as a software architect and mobile app strategist, with specialism in Android development. Engage me either freelance or via the mobile app agency Apptaura which I co-founded. Find me on LinkedIn or Bluesky.

This article was previously published on proandroiddev.com.

Menu