Securing Your APIs: Passwordless Authentication with WebAuthn
Passwords have long been the cornerstone of online authentication, but they come with significant drawbacks. Users often struggle to create and remember strong passwords, leading to weak security practices such as password reuse and susceptibility to phishing attacks. To address these challenges, the industry is shifting toward passwordless authentication—a model that enhances both security and user experience by eliminating passwords altogether.
At the forefront of this movement are WebAuthn (Web Authentication API) and the CTAP2 standard (Client to Authenticator Protocol 2) under the FIDO2 umbrella. These technologies leverage public-key cryptography and device-based authenticators to provide a robust, phishing-resistant authentication mechanism. WebAuthn is a web standard published by the World Wide Web Consortium (W3C) that enables web clients to communicate with authenticators. CTAP2 defines how a client (like your PC, phone, or browser) communicates with an authenticator. Together, they form the foundation of FIDO2, which enables us to move beyond passwords.
In this article, we are going to explore how to implement passwordless authentication. As our client will be a web browser, we don't need to interact directly with CTAP2; it's handled by the browser under the hood. So we will focus on WebAuthn, which is the web API that browsers expose to web applications, and we will call it to create and use passwordless credentials.
This guide explores the implementation of WebAuthn using the Yubico WebAuthn library, demonstrating both passkey registration and authentication flows. The implementation showcases how modern web applications can eliminate passwords while enhancing both security and user experience.
Requirements
To follow along with this guide, you will need the following tools and libraries:
- Latest version of Google Chrome, Mozilla Firefox, Microsoft Edge, or Apple Safari
- A FIDO2-compliant authenticator (e.g., YubiKey)
- Yubico's WebAuthn library
Please add the following dependency to your build.sbt file:
libraryDependencies += "com.yubico" % "webauthn-server-core" % "2.7.0"
libraryDependencies += "com.yubico" % "webauthn-server-attestation" % "2.7.0"
Key Concepts
Before diving into the details of how WebAuthn works and the implementation details, let's define some key concepts and terminology:
- Relying Party (RP): The server or service that wants to authenticate the user and is responsible for generating challenges and verifying responses.
- Client (User Agent): The web browser or application that mediates between the Relying Party and the Authenticator using the WebAuthn API.
- Authenticator: A device or software that proves your identity by generating or storing credentials (like cryptographic keys, passwords, or biometric data).
- Platform authenticator: Built into the device (e.g., Touch ID, Windows Hello, Android biometrics)
- Roaming authenticator: External devices that can be used across multiple platforms (e.g., YubiKey, FIDO2 USB Keys)
- Challenge: A unique, server-generated random value sent to the authenticator to be signed, ensuring the freshness of each authentication attempt (preventing replay attacks) and allowing the relying party to confirm the response truly comes from the rightful account owner's authenticator that has the right private key.
- User Presence: A flag that the relying party can request to ensure that the user is physically present during the authentication process (e.g., by touching the security key or using biometrics).
- User Verification: A flag that the relying party can request to ensure that the user has been verified by the authenticator (e.g., using a PIN or biometric verification). It is more stringent than user presence, which only requires a simple action like touching the authenticator.
- Credential ID: A unique identifier for a credential (public/private key pair) created by the authenticator during registration. The relying party uses this ID to look up the associated public key during authentication.
FIDO2: The Umbrella Project
FIDO2 is an umbrella project that encompasses two main components: WebAuthn and CTAP2. Together, they enable passwordless authentication using public-key cryptography and device-based authenticators.
WebAuthn is part of the FIDO standard, which is published by the World Wide Web Consortium (W3C). It is a JavaScript API that enables web clients to interact with authenticators. It has two main operations:
navigator.credentials.create()
: Takes registration options and asks the authenticator to create a new credential (a pair of private and public keys).navigator.credentials.get()
: Takes authentication options and asks the authenticator to sign a given challenge with the private key.
We will discuss these operations in more detail later.
CTAP2 is a client-to-authenticator protocol that defines how a client (like your PC, phone, or browser) communicates with an authenticator. As we are going to write a web application, we don't need to interact directly with CTAP2; it's handled by the browser under the hood.
Why and How WebAuthn is Secure
WebAuthn's security architecture represents a paradigm shift from password-based authentication, addressing fundamental vulnerabilities that have plagued online security for decades. In this guide, we aren't going to cover security in depth, but the key aspects that make WebAuthn a robust and secure authentication mechanism are:
- At its core, WebAuthn uses asymmetric cryptography, where each user has a unique public/private key pair. The private key remains securely stored on the user's device (the authenticator), while the public key is registered with the service (the relying party). This design ensures that even if a service's database is compromised, attackers cannot derive the private keys, rendering stolen data useless for authentication.
- During authentication, the server issues a unique, random challenge that the authenticator must sign using its private key. This challenge-response mechanism ensures that only the legitimate user can authenticate, as the private key never leaves the device. Additionally, the challenge is unique for each authentication attempt, preventing replay attacks.
- WebAuthn is architecturally designed to be phishing-resistant through origin binding. When you want to log in with WebAuthn, the authenticator checks the website's origin against the origin you registered with. If there is a subtle difference that the human eye might not notice, the authenticator will refuse to sign the challenge. This mechanism effectively thwarts phishing attacks, as attackers cannot trick users into authenticating on fraudulent sites.
Discoverable and Non-Discoverable Credentials
Let's define what discoverable and non-discoverable credentials are:
- Non-discoverable credentials: These credentials require the user to provide a username or identifier during the authentication process. When the user attempts to log in, they must first enter their username, which the relying party uses to look up the associated credentials. The relying party asks the browser to use them in the authentication process. In this type of credential, the authenticator may not know which credential to use until the relying party tells it. Please note that sometimes non-resident keys are also called non-discoverable credentials; this is because these types of credentials are not required to be stored on the authenticator devices. Instead, it is the responsibility of the relying party to store them. They only have an internal mechanism to derive the private key from their master secret and the credential information that the relying party provides.
- Discoverable Credentials: These credentials can be used without the user providing a username or identifier. The authenticator itself stores the user information alongside the credential, allowing it to identify and use the correct credential during authentication. This enables a more seamless and user-friendly experience, as users can authenticate without needing to remember the username. During authentication, the authenticator prompts the user to select from a list of available credentials. These types of credentials are also called resident keys because they are stored on the authenticator device itself.
There is another term you may hear in this context: passkey. Passkeys are discoverable credentials that can be synced across devices through providers such as Google Password Manager, iCloud Keychain, and Windows Hello. So they are not device-bound and can be used from any device.
In this guide, we will cover both types of credentials and demonstrate how to implement them using the Yubico WebAuthn library.
Registration and Authentication Flows
After learning the key concepts, we are ready to explore the registration and authentication flows, but in the simplest form.
Implementation Overview
Based on the registration and authentication flows, the client and server sides each have distinct responsibilities:
- Client-side (Browser) is responsible for communicating between the Relying Party (your server) and the Authenticator to:
- Create new credentials (public/private key pair)
- Sign challenges to prove the authenticator possesses the private key
- Server-side is responsible for:
- Generating a challenge
- Verifying the registration and authentication responses from the client
- Storing and managing user credentials (public keys)
- Managing user sessions
Registration Flow Implementation
The registration flow involves the following steps:
- Client (Browser) requests registration options from the Relying Party (your server).
- Relying Party generates a challenge and sends registration options to the Client.
- Client invokes the authenticator to create a new credential (public/private key pair).
- Authenticator creates the credential and returns it to the Client.
- Client sends the credential to the Relying Party.
- Relying Party verifies the credential and stores the public key.
- If verification is successful, the Relying Party associates the public key with the user's account.
- Relying Party confirms successful registration to the Client.
- Client confirms successful registration to the User.
To manage the registration process, we define a WebAuthnService
trait with two methods: startRegistration
and finishRegistration
:
trait WebAuthnService {
def startRegistration(request: RegistrationStartRequest): IO[String, RegistrationStartResponse]
def finishRegistration(request: RegistrationFinishRequest): IO[String, RegistrationFinishResponse]
}
- The
startRegistration
method is responsible for generating public key creation options to be sent to the client. The client uses these options to prompt the authenticator to create a new credential. - The
finishRegistration
method is responsible for verifying the response from the client and storing the public key associated with the user to complete the registration process.
Each of these two methods corresponds to an API endpoint:
POST /api/webauthn/registration/start
: This endpoint initiates the registration process by generating and returning the public key creation options to the client.POST /api/webauthn/registration/finish
: This endpoint completes the registration process by verifying the response from the client.
Let's start by implementing the start and finish routes for the registration flow.
Registration Routes
1. Start Route
The start endpoint is responsible for generating the registration options and sending them to the client:
val registrationStartRoute =
Method.POST / "api" / "webauthn" / "registration" / "start" -> handler { (req: Request) =>
for {
request <- req.body.to[RegistrationStartRequest]
response <- webauthn.startRegistration(request.username)
} yield Response.json(response.toJson)
}
It takes a RegistrationStartRequest
object containing the username, and then it calls the startRegistration
method of the WebAuthnService
and returns a PublicKeyCredentialCreationOptions
object containing the registration options. The RegistrationStartRequest
and RegistrationStartResponse
types are defined as follows:
case class RegistrationStartRequest(username: String)
type RegistrationStartResponse =
com.yubico.webauthn.data.PublicKeyCredentialCreationOptions
RegistrationStartRequest
contains the username of the user who wants to register a new credential.RegistrationStartResponse
is a type alias ofPublicKeyCredentialCreationOptions
from the Yubico WebAuthn library, which contains the public key credential options that the client needs to create a new credential.
What are registration options? They are all the information the client needs to create a new key pair credential. It includes the challenge, relying party information, user information, and other parameters.
To deserialize the request body into a RegistrationStartRequest
object, we need to define a JSON codec for it:
import zio.json._
object RegistrationStartRequest {
implicit val codec: JsonCodec[RegistrationStartRequest] = DeriveJsonCodec.gen
}
As the RegistrationStartResponse
data type is a type alias of PublicKeyCredentialCreationOptions
, it supports JSON serialization out of the box. We can directly convert it to JSON using the PublicKeyCredentialCreationOptions#toJson
method.
This route is responsible for generating the PublicKeyCredentialCreationOptions
which looks like this:
{
"rp": {
"name": "WebAuthn Demo",
"id": "localhost"
},
"user": {
"name": "john",
"displayName": "john",
"id": "OTk4NDdkZjItZTRkNy00N2YyLTgxNTEtZTljY2FhODhkZTZl"
},
"challenge": "KvgPsSdLEyhLaB9r9m3EQs6UyunnPHopdGmM1G2SlE4",
"pubKeyCredParams": [
{
"alg": -8,
"type": "public-key"
},
{
"alg": -7,
"type": "public-key"
},
{
"alg": -257,
"type": "public-key"
}
],
"timeout": 60000,
"hints": [],
"authenticatorSelection": {
"requireResidentKey": true,
"residentKey": "required",
"userVerification": "required"
},
"attestation": "none",
"extensions": {}
}
Here is an overview of each field:
rp
: Contains information about the relying party (your server), including its name and ID (domain). Therp.id
must match the domain you want the credential scoped to (no scheme, no port), e.g.,example.com
orlogin.example.com
. If you set it tologin.example.com
, the credential will not work onexample.com
orshop.example.com
. Conversely, if you set it toexample.com
, the credential will work on all subdomains. Therp.name
is a human-readable name for the relying party, which is shown to the user in the authenticator UI.user
: Contains information about the user, including their username, display name, and a unique identifier (ID). Theuser.id
is a server-generated unique identifier for the user, encoded as a base64url string. Please note that the "user id" is also called "user handle" in the WebAuthn specification, so they are the same thing. Theuser.name
is the username of the user, and theuser.displayName
is a human-readable name for the user, which is shown to the user in the authenticator UI.challenge
: A server-generated random value (base64url-encoded) that the authenticator must sign to prove possession of the private key.pubKeyCredParams
: An array of objects specifying the acceptable public key algorithms for the credential. Each object contains analg
field (COSE algorithm identifier) and atype
field (always "public-key").timeout
: The time (in milliseconds) that the client should wait for the user to complete the registration process.hints
: An array of strings that provide hints to the client (browser) about which type of authenticator to use and what the order of our preference is.authenticatorSelection
: An object that specifies criteria for selecting the authenticator. It includes:requireResidentKey
: A boolean indicating whether a resident key (discoverable credential) is required.residentKey
: A string indicating the requirement level for resident keys ("required", "preferred", or "discouraged").userVerification
: A string indicating the requirement level for user verification ("required", "preferred", or "discouraged").
attestation
: Defines the attestation conveyance preference, which indicates how the relying party wants to receive attestation information from the authenticator. It can be "none", "indirect", or "direct". In this example, we set it to "none" to simplify the process and avoid dealing with attestation verification.extensions
: An object that can contain additional extension data for the registration process. In this example, we leave it empty.
2. Finish Route
After the client sends the registration start request and receives the registration options, it invokes the authenticator to create a new credential. The authenticator returns the credential to the client, which then sends it to the relying party to finish the registration ceremony. So we have to implement the finish endpoint to handle this request.
The finish endpoint is responsible for verifying the response from the client and storing the public key:
val registrationFinishRoute =
Method.POST / "api" / "webauthn" / "registration" / "finish" -> handler { (req: Request) =>
for {
body <- req.body.asString
request <- ZIO.fromEither(body.fromJson[RegistrationFinishRequest])
result <- webauthn.finishRegistration(request).orElseFail {
Response(
status = Status.InternalServerError,
body = Body.fromString(s"Registration failed!"),
)
}
} yield Response(body = Body.from(result))
}
It takes a RegistrationFinishRequest
object containing the response from the client, then it calls the finishRegistration
method of the WebAuthnService
, and returns a RegistrationFinishResponse
object indicating whether the registration was successful. The RegistrationFinishRequest
and RegistrationFinishResponse
types are defined as follows:
case class RegistrationFinishRequest(
username: String,
userhandle: String,
publicKeyCredential: PublicKeyCredential[AuthenticatorAttestationResponse, ClientRegistrationExtensionOutputs],
)
case class RegistrationFinishResponse(
success: Boolean,
credentialId: String,
)
- The
RegistrationFinishRequest
contains a username, user handle, and thePublicKeyCredential
object returned by the authenticator. ThePublicKeyCredential
is a generic type that takes two type parameters: the response type and the extension outputs type. In this case, we useAuthenticatorAttestationResponse
as the response type andClientRegistrationExtensionOutputs
as the extension outputs type. - The
RegistrationFinishResponse
contains a success flag and the credential ID of the newly created credential.
Please note that we have two types of authenticator responses:
AuthenticatorAttestationResponse
: Used during the registration ceremony and contains the attestation object and client data JSON.AuthenticatorAssertionResponse
: Used during the authentication ceremony and contains the authenticator data, client data JSON, signature, and user handle.
As we are implementing the registration flow, we use AuthenticatorAttestationResponse
.
Here is an example response from the authenticator after the client prompts the authenticator to create a new credential:
{
"username": "milad",
"userhandle": "cfaf4026-21e7-4bfb-909c-9b514fc52ac4",
"publicKeyCredential": {
"authenticatorAttachment": "cross-platform",
"clientExtensionResults": {},
"id": "xil3bQiqYNMnsTD6JV6LZA",
"rawId": "xil3bQiqYNMnsTD6JV6LZA",
"response": {
"attestationObject": "o2NmbXRkbm9uZWdhdHRTdG10oGhhdXRoRGF0YViUSZYN5YgOjGh0NBcPZHZgW4_krrmihjLHmVzzuoMdl2NdAAAAAOqbjWZNAR0hPOS2tIy1ddQAEMYpd20IqmDTJ7Ew-iVei2SlAQIDJiABIVgggd6fPJYYYdbHuIwo_F3NhNtQS0NkK71IEN_hasDbLxUiWCAfUA-DngJiyOy2X2Ze4qWp6zIZA2wG5ymfS3zBMHd8VA",
"authenticatorData": "SZYN5YgOjGh0NBcPZHZgW4_krrmihjLHmVzzuoMdl2NdAAAAAOqbjWZNAR0hPOS2tIy1ddQAEMYpd20IqmDTJ7Ew-iVei2SlAQIDJiABIVgggd6fPJYYYdbHuIwo_F3NhNtQS0NkK71IEN_hasDbLxUiWCAfUA-DngJiyOy2X2Ze4qWp6zIZA2wG5ymfS3zBMHd8VA",
"clientDataJSON": "eyJ0eXBlIjoid2ViYXV0aG4uY3JlYXRlIiwiY2hhbGxlbmdlIjoiSzg2RUZVMTdMSzB5LXFMOVh0dDJYLXdyMnVaaUtxaC13bFJtRlpUWGxGdyIsIm9yaWdpbiI6Imh0dHA6Ly9sb2NhbGhvc3Q6ODA4MCIsImNyb3NzT3JpZ2luIjpmYWxzZX0",
"publicKey": "MFkwEwYHKoZIzj0CAQYIKoZIzj0DAQcDQgAEgd6fPJYYYdbHuIwo_F3NhNtQS0NkK71IEN_hasDbLxUfUA-DngJiyOy2X2Ze4qWp6zIZA2wG5ymfS3zBMHd8VA",
"publicKeyAlgorithm": -7,
"transports": [
"hybrid",
"internal"
]
},
"type": "public-key"
}
}
It contains the public key credential created by the authenticator, along with the username and user handle. Using this information, the server can verify the credential and store the public key associated with the user.
To be able to serialize and deserialize the RegistrationFinishRequest
and RegistrationFinishResponse
types to and from JSON, we need to define JSON codecs for them.
For the RegistrationFinishRequest
, we define a JSON decoder as below:
object RegistrationFinishRequest {
implicit val decoder: JsonDecoder[RegistrationFinishRequest] =
JsonDecoder[Json].mapOrFail { o =>
for {
u <- o.get(JsonCursor.field("username")).flatMap(_.as[String])
uh <- o.get(JsonCursor.field("userhandle")).flatMap(_.as[String])
pkc <- o
.get(JsonCursor.field("publicKeyCredential"))
.map(_.toString())
.map(PublicKeyCredential.parseRegistrationResponseJson)
} yield RegistrationFinishRequest(u, uh, pkc)
}
}
As the PublicKeyCredential
type has built-in JSON parsing support in the Yubico WebAuthn library, instead of reinventing the wheel, we can use the PublicKeyCredential.parseRegistrationResponseJson
method to parse the publicKeyCredential
field from the JSON object.
For the RegistrationFinishResponse
, we can define a JSON codec straightforwardly as below:
object RegistrationFinishResponse {
implicit val codec: JsonCodec[RegistrationFinishResponse] = DeriveJsonCodec.gen
}
It helps us to directly convert the RegistrationFinishResponse
object to the JSON format when returning it in the response.
WebAuthn Service
The WebAuthnService
interface is responsible for handling the core logic of the registration and authentication flow. In this section, we will focus on implementing the registration flow, which includes the startRegistration
and finishRegistration
methods:
trait WebAuthnService {
def startRegistration(request: RegistrationStartRequest): IO[String, RegistrationStartResponse]
def finishRegistration(request: RegistrationFinishRequest): IO[String, RegistrationFinishResponse]
}
- The
startRegistration
method is responsible for generating public key creation options to be sent to the client. The client uses these options to prompt the authenticator to create a new credential. - The
finishRegistration
method is responsible for verifying the response from the client and storing the public key associated with the user to complete the registration process.
Let's start by implementing the startRegistration
method.
1. Start Registration
To implement the startRegistration
method, we should perform the following steps:
- Check if the user exists in our system. If not, we create a new user with the given username.
- Generate the registration options (
PublicKeyCredentialCreationOptions
) using the Yubico WebAuthn library. - Store the registration options somewhere (e.g., in-memory map or database) so that we can retrieve them later when the client sends the response to finish the registration ceremony.
Now we are ready to implement the WebAuthnService
:
class WebAuthnServiceImpl(
userService: UserService,
pendingRegistrations: Ref[Map[UserHandle, RegistrationStartResponse]],
) extends WebAuthnService {
private val relyingPartyIdentity: RelyingPartyIdentity = ???
private val relyingParty : RelyingParty = ???
private def userIdentity(userId: String, username: String): UserIdentity = ???
override def startRegistration(request: RegistrationStartRequest): UIO[RegistrationStartResponse] =
userService
.getUser(request.username)
.orElse {
val user = User(UUID.randomUUID().toString, request.username, Set.empty)
userService.addUser(user).as(user)
}
.orDieWith(_ => new IllegalStateException("Unexpected status in registration flow!"))
.flatMap { user =>
val creationOptions = generateCreationOptions(relyingPartyIdentity, userIdentity(user.userHandle, request.username));
pendingRegistrations.update(_.updated(user.userHandle, creationOptions)).as(creationOptions)
}
override def finishRegistration(
request: RegistrationFinishRequest,
): ZIO[Any, String, RegistrationFinishResponse] = ???
}
The startRegistration
method first checks if the user exists in the system. If not, it creates a new user with the given username. It generates a random user ID (user handle) for the new user. Then it generates the registration options by calling the generateCreationOptions
method. The generateCreationOptions
takes two parameters: RelyingPartyIdentity
and UserIdentity
. The RelyingPartyIdentity
contains information about the relying party (your server), and the UserIdentity
contains information about the user.
We used the generateCreationOptions
helper method to create the PublicKeyCredentialCreationOptions
object, which can be defined as follows:
def generateCreationOptions(
relyingPartyIdentity: RelyingPartyIdentity,
userIdentity: UserIdentity,
timeout: Duration = 1.minutes,
): PublicKeyCredentialCreationOptions = {
PublicKeyCredentialCreationOptions
.builder()
.rp(relyingPartyIdentity)
.user(userIdentity)
.challenge(generateChallenge())
.pubKeyCredParams(
List(
PublicKeyCredentialParameters.EdDSA,
PublicKeyCredentialParameters.ES256,
PublicKeyCredentialParameters.RS256,
).asJava,
)
.authenticatorSelection(
AuthenticatorSelectionCriteria
.builder()
.residentKey(ResidentKeyRequirement.REQUIRED)
.userVerification(UserVerificationRequirement.REQUIRED)
.build(),
)
.attestation(AttestationConveyancePreference.NONE)
.timeout(timeout.toMillis)
.build()
}
The generateChallenge
function generates a unique challenge using a secure random number generator:
object Crypto {
val secureRandom: SecureRandom = new SecureRandom()
}
def generateChallenge(): ByteArray = {
val bytes = new Array[Byte](32)
Crypto.secureRandom.nextBytes(bytes)
new ByteArray(bytes)
}
The RelyingPartyIdentity
can be defined as follows:
val relyingPartyIdentity: RelyingPartyIdentity =
RelyingPartyIdentity
.builder()
.id("localhost")
.name("WebAuthn Demo")
.build()
It specifies the relying party ID (domain) and name. Similarly, we have to generate the UserIdentity
object based on the user information:
def userIdentity(userId: String, username: String): UserIdentity =
UserIdentity
.builder()
.name(username)
.displayName(username)
.id(new ByteArray(userId.getBytes()))
.build()
After creating credential creation options, before returning it to the client, we need to store it somewhere so that we can retrieve it later when the client sends the response to finish the registration ceremony. To keep track of the registration attempts, we store it in an in-memory map (pendingRegistrations
) with the user handle as the key.
2. Finish Registration
Now, let's see how to implement the finishRegistration
method in the WebAuthnService
. In this method, we have to perform the following steps:
- Find the corresponding pending registration options for the user handle provided in the request. Why? Because we need to verify the response from the client against the original challenge and options that were sent to the client during the registration start step.
- To verify the received response, we use the
RelyingParty#finishRegistration
method from the Yubico WebAuthn library. This method takes aFinishRegistrationOptions
object, which contains the original registration options and the response from the client. It performs all the necessary validations and returns aRegistrationResult
object. - If the registration is successful and the user is verified, we store the public key and other relevant information in our user service. This enables us to authenticate the user in future login attempts.
- Finally, we return a
RegistrationFinishResponse
object, which contains a success flag and the credential ID.
Here is our implementation of the finishRegistration
method:
class WebAuthnServiceImpl(
userService: UserService,
pendingRegistrations: Ref[Map[UserHandle, RegistrationStartResponse]],
) extends WebAuthnService {
private val relyingPartyIdentity: RelyingPartyIdentity = ???
private val relyingParty: RelyingParty = ???
private def userIdentity(userId: String, username: String): UserIdentity = ???
override def finishRegistration(
request: RegistrationFinishRequest,
): IO[String, RegistrationFinishResponse] =
for {
creationOptions <- pendingRegistrations.get
.map(_.get(request.userhandle))
.some
.orElseFail(s"no registration request found for ${request.username} username")
result = relyingParty.finishRegistration(
FinishRegistrationOptions
.builder()
.request(creationOptions)
.response(request.publicKeyCredential)
.build(),
)
_ <- userService
.addCredential(
userHandle = request.userhandle,
credential = UserCredential(
userHandle = creationOptions.getUser.getId,
credentialId = result.getKeyId.getId,
publicKeyCose = result.getPublicKeyCose,
signatureCount = result.getSignatureCount,
),
)
.orElseFail(s"${request.username} user not found!")
_ <- pendingRegistrations.update(_.removed(request.userhandle))
} yield {
RegistrationFinishResponse(
success = true,
credentialId = result.getKeyId.getId.getBase64Url,
)
}
}
The finishRegistration
method uses the RP (Relying Party) instance. It can be created as follows:
val relyingPartyIdentity: RelyingPartyIdentity =
RelyingPartyIdentity
.builder()
.id("localhost")
.name("WebAuthn Demo")
.build()
val relyingParty: RelyingParty =
RelyingParty
.builder()
.identity(relyingPartyIdentity)
.credentialRepository(new InMemoryCredentialRepository(userService))
.origins(Set("http://localhost:8080").asJava)
.build()
To create a RelyingParty
, we need to provide the RelyingPartyIdentity
, a CredentialRepository
, and a set of allowed origins. The CredentialRepository
is an interface that the Yubico WebAuthn library uses to look up credentials during the authentication process. We will implement it later. Before that, let's see how we can manage users and their credentials.
The important part about both the registration and authentication processes is the persistence of user information and credentials. To manage users and their credentials, we define the UserService
that supports the following operations:
trait UserService {
def getUser(username: String): IO[UserNotFound, User]
def getUserByHandle(userHandle: String): IO[UserNotFound, User]
def addUser(user: User): IO[UserAlreadyExists, Unit]
def addCredential(userHandle: String, credential: UserCredential): IO[UserNotFound, Unit]
def getCredentialById(credentialId: String): UIO[Set[UserCredential]]
}
Where the User
and UserCredential
types are defined as follows:
case class User(
userHandle: String,
username: String,
credentials: Set[UserCredential],
)
case class UserCredential(
userHandle: ByteArray,
credentialId: ByteArray,
publicKeyCose: ByteArray,
signatureCount: Long,
)
The UserCredential
type represents a public key credential extracted from the RegistrationResult
object returned by RelyingParty#finishRegistration
. It contains the user handle, credential ID, public key in COSE format, and signature count and we will store it in our user service.
The UserServiceError
type is defined as follows:
sealed trait UserServiceError
object UserServiceError {
case class UserNotFound(username: String) extends UserServiceError
case class UserAlreadyExists(username: String) extends UserServiceError
}
As the implementation of UserService
is not the main focus of this guide, we will provide a simple in-memory implementation:
case class UserServiceLive(users: Ref[Map[String, User]]) extends UserService {
override def getUser(username: String): IO[UserNotFound, User] =
users.get.flatMap { userMap =>
ZIO.fromOption(userMap.get(username)).orElseFail(UserNotFound(username))
}
override def addUser(user: User): IO[UserAlreadyExists, Unit] =
users.get.flatMap { userMap =>
ZIO.when(userMap.contains(user.username)) {
ZIO.fail(UserAlreadyExists(user.username))
} *> users.update(_.updated(user.username, user))
}
override def addCredential(userHandle: String, credential: UserCredential): IO[UserNotFound, Unit] =
users.get.flatMap { userMap =>
ZIO.fromOption(userMap.values.find(_.userHandle == userHandle)).orElseFail(UserNotFound(userHandle)).flatMap {
user =>
val updatedCredentials = user.credentials + credential
val updatedUser = user.copy(credentials = updatedCredentials)
users.update(_.updated(user.username, updatedUser))
}
}
override def getCredentialById(credentialId: String): IO[Nothing, Set[UserCredential]] =
users.get.map { userMap =>
userMap.values
.flatMap(_.credentials)
.filter(_.credentialId.getBytes.sameElements(credentialId.getBytes))
.toSet
}
override def getUserByHandle(userHandle: String): IO[UserNotFound, User] =
users.get.flatMap { userMap =>
ZIO.fromOption(userMap.values.find(_.userHandle == userHandle)).orElseFail(UserNotFound(userHandle))
}
}
These methods allow us to manage users and their credentials, and also help us to implement the CredentialRepository
interface required by the Yubico WebAuthn library. Let's see how we can implement it:
import zio._
import com.yubico.webauthn._
import com.yubico.webauthn.data._
import example.auth.webauthn.model.UserCredential
import java.util
import java.util.Optional
import scala.jdk.CollectionConverters._
import scala.jdk.OptionConverters.RichOption
class InMemoryCredentialRepository(userService: UserService) extends CredentialRepository {
override def getCredentialIdsForUsername(username: String): util.Set[PublicKeyCredentialDescriptor] =
Unsafe.unsafe { implicit unsafe =>
Runtime.default.unsafe.run {
userService
.getUser(username)
.map(_.credentials)
.orElseSucceed(Set.empty)
.map { _.map { cred =>
PublicKeyCredentialDescriptor
.builder()
.id(cred.credentialId)
.build()
}
}
.map(_.toSet)
.map(_.asJava)
}.getOrThrow()
}
override def getUserHandleForUsername(username: String): Optional[ByteArray] =
Unsafe.unsafe { implicit unsafe =>
Runtime.default.unsafe.run {
userService
.getUser(username)
.map { user =>
new ByteArray(user.userHandle.getBytes())
}
.option
}.getOrThrow()
}.toJava
override def getUsernameForUserHandle(userHandle: ByteArray): Optional[String] =
Unsafe.unsafe { implicit unsafe =>
Runtime.default.unsafe.run {
userService.getUserByHandle(new String(userHandle.getBytes)).map(_.username).option
}.getOrThrow()
}.toJava
override def lookup(credentialId: ByteArray, userHandle: ByteArray): Optional[RegisteredCredential] =
Unsafe.unsafe { implicit unsafe =>
Runtime.default.unsafe.run {
userService
.getUserByHandle(new String(userHandle.getBytes))
.flatMap { user =>
val credOpt = user.credentials.find(_.credentialId == credentialId)
credOpt match {
case Some(cred) => ZIO.succeed(cred)
case None => ZIO.fail(new Exception("Credential not found"))
}
}
.map(toRegisteredCredential)
.option
}.getOrThrow()
}.toJava
override def lookupAll(credentialId: ByteArray): util.Set[RegisteredCredential] =
Unsafe.unsafe { implicit unsafe =>
Runtime.default.unsafe.run {
userService.getCredentialById(new String(credentialId.getBytes))
}.getOrThrow().map(toRegisteredCredential).asJava
}
private def toRegisteredCredential(cred: UserCredential): RegisteredCredential =
RegisteredCredential
.builder()
.credentialId(cred.credentialId)
.userHandle(cred.userHandle)
.publicKeyCose(cred.publicKeyCose)
.signatureCount(cred.signatureCount)
.build()
}
In this implementation, we use the UserService
to look up users and their credentials. As we are touching the boundary between ZIO and Java, we need to use Unsafe.unsafe
and Runtime.default.unsafe.run
to run ZIO effects and get the results.
Client-Side Implementation
Let's start by implementing the registration flow by writing the client-side registration form:
<label for="passkey-username">Choose a Username</label>
<input type="text" id="passkey-username" placeholder="e.g., john" autocomplete="username"/>
<button onclick="registerPasskey()">Create Passkey</button>
After the user enters a username and clicks the "Create Passkey" button, the registerPasskey
function is called. This function retrieves the username from the input field and initiates the registration process by calling the performRegistration
function:
async function registerPasskey() {
const username = document.getElementById("passkey-username").value;
if (!username) {
alert("Please enter a username to register")
return;
}
const success = await performRegistration({ username });
if (success) {
document.getElementById("passkey-username").value = "";
alert("Passkey registered successfully!");
}
}
The performRegistration
function handles the entire registration flow:
async function performRegistration({ username }) {
try {
const requestBody = { username };
const startRes = await fetch("/api/webauthn/registration/start", {
method: "POST",
headers: { "Content-Type": "application/json" },
body: JSON.stringify(requestBody),
});
if (!startRes.ok) throw new Error("Failed to start registration");
const serverOptions = await startRes.json();
const credential = await navigator.credentials.create({
publicKey: PublicKeyCredential.parseCreationOptionsFromJSON(serverOptions),
});
const credentialJSON = credential.toJSON();
const credentialForServer = {
username: username,
userhandle: atob(serverOptions.user.id),
publicKeyCredential: credentialJSON,
};
const finishRes = await fetch("/api/webauthn/registration/finish", {
method: "POST",
headers: { "Content-Type": "application/json" },
body: JSON.stringify(credentialForServer),
});
let resBody = await finishRes.json()
if (!finishRes.ok) throw new Error("Failed to finish registration");
alert("Passkey registered successfully! You can now sign in using your passkey.");
return true;
} catch (err) {
alert("Failed to register passkey: " + err.message);
return false;
}
}
The performRegistration
function performs the following steps:
- Sends a
POST
request to the/api/webauthn/registration/start
endpoint with the username to initiate the registration process. The server responds with registration options. - Parses the returned options using
PublicKeyCredential.parseCreationOptionsFromJSON
, then callsnavigator.credentials.create()
to prompt the authenticator to generate a new credential. - Converts the created credential to JSON using the
toJSON()
method. - Sends another
POST
request to the/api/webauthn/registration/finish
endpoint with the username, user handle, and serialized credential to complete the registration. - If registration succeeds, it notifies the user with a success message and returns
true
. If any step fails, it alerts the user with an error message and returnsfalse
.
Authentication Flow Implementation
After successful registration, the user can authenticate using their passkey. The authentication flow involves the following steps:
- Client (Browser) requests authentication options from the Relying Party (your server).
- Relying Party generates a challenge and sends authentication options to the Client.
- Client invokes the Authenticator to sign the challenge to prove that the authenticator possesses the private key.
- Authenticator signs the challenge and returns the signature to the Client.
- Client sends the signature to the Relying Party.
- Relying Party verifies the signature using the stored public key that is associated with the user (this public key was stored during the registration flow).
- If the signature is valid, the Relying Party confirms successful authentication to the Client.
The authentication process is similar to the registration process and involves two main steps: starting the authentication ceremony and finishing it. Let's add them to the WebAuthnService
interface:
trait WebAuthnService {
def startRegistration(request: RegistrationStartRequest): IO[String, RegistrationStartResponse]
def finishRegistration(request: RegistrationFinishRequest): IO[String, RegistrationFinishResponse]
// two new operations
def startAuthentication(request: AuthenticationStartRequest): IO[String, AuthenticationStartResponse]
def finishAuthentication(request: AuthenticationFinishRequest): IO[String, AuthenticationFinishResponse]
}
- The
startAuthentication
method is responsible for generating an assertion request to be sent to the client. This request contains a challenge and other options required for authentication. The client uses this request to prompt the authenticator to sign the challenge. - The
finishAuthentication
method is responsible for verifying the authenticator response received from the client. It checks the signature provided by the authenticator against the stored public key associated with the user. If the signature is valid, it confirms successful authentication.
Each of these two methods corresponds to an API endpoint:
POST /api/webauthn/authentication/start
: This endpoint initiates the authentication process by generating and returning the assertion request to the client.POST /api/webauthn/authentication/finish
: This endpoint completes the authentication process by verifying the response from the client.
Authentication Routes
Recall that we discussed two types of registrations and authentications: with username and without username. The username-less authentication is more user-friendly and seamless, but it requires the authenticator to support resident keys (discoverable credentials). In this guide, we will implement the authentication flow that supports both types of authentication.
1. Start Route
The start route is responsible for generating the assertion request to be sent to the client. This request contains a challenge and other options required for authentication. The client uses this request to prompt the authenticator to sign the challenge:
val authenticationStartRoute =
Method.POST / "api" / "webauthn" / "authentication" / "start" -> handler { (req: Request) =>
for {
request <- req.body.to[AuthenticationStartRequest]
response <- webauthn.startAuthentication(request)
} yield Response.json(response.toJson)
}
The start route takes a JSON object of type AuthenticationStartRequest
and returns a JSON object of type AuthenticationStartResponse
:
case class AuthenticationStartRequest(username: Option[String])
type AuthenticationStartResponse = AssertionRequest
AuthenticationStartRequest
: We made theusername
field ofAuthenticationStartRequest
optional to support both types of authentication flows (with username and without username). For username-less authentication, we will sendnull
or omit theusername
field in the JSON object.AuthenticationStartResponse
: It is a type alias forAssertionRequest
from the Yubico WebAuthn library, which containsPublicKeyCredentialRequestOptions
and optional username and user handle.
To be able to deserialize the incoming request to its corresponding model, i.e., AuthenticationStartRequest
, we have to write a codec for it:
object AuthenticationStartRequest {
implicit val codec: JsonCodec[AuthenticationStartRequest] = DeriveJsonCodec.gen
}
The start route responds with an AuthenticationStartResponse
object, which is a type alias for the AssertionRequest
data type from the Yubico WebAuthn library. This contains PublicKeyCredentialRequestOptions
and optional username and user handle. To convert the AuthenticationStartResponse
to JSON format, we can use the built-in AssertionRequest#toJson
method. So we do not need to write a custom codec for AuthenticationStartResponse
.
Here is an example response of type AuthenticationStartResponse
from the server if no username is provided in the request:
{
"publicKeyCredentialRequestOptions": {
"challenge": "nrihUD006_f5FP75E3Ntq5Up136pvAXpYm_nThFVJdY",
"timeout": 60000,
"hints": [],
"rpId": "localhost",
"userVerification": "required",
"extensions": {}
}
}
It contains the publicKeyCredentialRequestOptions
field, which is of type PublicKeyCredentialRequestOptions
. This object contains all the necessary information for the client to prompt the authenticator to sign the challenge:
- challenge: A unique challenge generated by the server to prevent replay attacks. The client must sign this challenge using the private key associated with the credential that is stored in the authenticator. This proves that the client possesses the private key.
- timeout: The time in milliseconds that the client has to complete the authentication process.
- rpId: The relying party identifier, which is typically the domain of the website.
- userVerification: Indicates the level of user verification required. In this case, it is set to "required", meaning the authenticator must verify the user's identity (e.g., via biometric or PIN).
If we send a username in the AuthenticationStartRequest
, the server will generate an assertion request specific to that user. This means that the server will look up the user's credentials and include them in the assertion request. This is useful for scenarios where the user wants to authenticate with a specific username. Besides the publicKeyCredentialRequestOptions
field, it also includes the username
or userhandle
field in the response. Here is an example response when a username (e.g., john
) is provided in the request:
{
"publicKeyCredentialRequestOptions": {
"challenge": "G4ARnC_LUO8u5EM5bS2BVUc8jB3zhB1vM-6-9pPn1us",
"timeout": 60000,
"hints": [],
"rpId": "localhost",
"allowCredentials": [
{
"type": "public-key",
"id": "y8gtZxKJg_55WumHTKD_dA"
},
{
"type": "public-key",
"id": "wX_IO_8fBxNT5-CyeHY9gg"
}
],
"userVerification": "required",
"extensions": {}
},
"username": "john"
}
The publicKeyCredentialRequestOptions
field contains an additional field called allowCredentials
. This field is a list of credentials that the server recognizes for the specified user. The client will use one of these credentials to authenticate. The username
field contains the username provided in the request.
2. Finish Route
On the client side, after receiving the assertion request from the server, the client prompts the authenticator to sign the challenge. This is done using the navigator.credentials.get()
method provided by the WebAuthn API.
Modern browsers have built-in support for parsing the JSON object directly, using the PublicKeyCredential.parseRequestOptionsFromJSON
method. So we can easily parse the received assertion request and convert it to PublicKeyCredentialRequestOptions
, which is required by the navigator.credentials.get()
method.
After prompting the authenticator and getting the assertion response, the client creates a JSON of type AuthenticationFinishRequest
and sends it to the server to finish the authentication process using the following endpoint:
val authenticationFinishRoute =
Method.POST / "api" / "webauthn" / "authentication" / "finish" -> handler { (req: Request) =>
for {
body <- req.body.asString
request <- ZIO.fromEither(body.fromJson[AuthenticationFinishRequest])
result <- webauthn
.finishAuthentication(request)
.orElseFail(
Response(
status = Status.Unauthorized,
body = Body.fromString(s"Authentication failed!"),
),
)
} yield Response(body = Body.from(result))
}
The finish route takes a JSON object of type AuthenticationFinishRequest
and returns a JSON object of type AuthenticationFinishResponse
:
case class AuthenticationFinishRequest(
username: Option[String], // Optional for discoverable passkeys
publicKeyCredential: PublicKeyCredential[AuthenticatorAssertionResponse, ClientAssertionExtensionOutputs],
)
case class AuthenticationFinishResponse(
success: Boolean,
username: String,
)
AuthenticationFinishRequest
: We made theusername
field ofAuthenticationFinishRequest
optional to support both types of authentication flows (with username and without username). For username-less authentication, we will sendnull
or omit theusername
field in the JSON object. ThepublicKeyCredential
field is of typePublicKeyCredential
from the Yubico WebAuthn library, which contains the assertion response from the authenticator.AuthenticationFinishResponse
: It contains a success flag and the username of the authenticated user.
Here is an example of the JSON object of type AuthenticationFinishRequest
sent to the server when no username is provided in the request:
{
"username" : null,
"publicKeyCredential": {
"authenticatorAttachment": "cross-platform",
"clientExtensionResults": {},
"id": "6XXK_FdqGAvpwXpHRTg-jQ",
"rawId": "6XXK_FdqGAvpwXpHRTg-jQ",
"response": {
"authenticatorData": "SZYN5YgOjGh0NBcPZHZgW4_krrmihjLHmVzzuoMdl2MdAAAAAA",
"clientDataJSON": "eyJ0eXBlIjoid2ViYXV0aG4uZ2V0IiwiY2hhbGxlbmdlIjoiMXRZTUJOMlFiMVd2a0RBMWFvaE5ReHJhc1BSZ2VlTU1wZTlQWHFYamhGVSIsIm9yaWdpbiI6Imh0dHA6Ly9sb2NhbGhvc3Q6ODA4MCIsImNyb3NzT3JpZ2luIjpmYWxzZX0",
"signature": "MEUCIDAL9bj4PdhzuKzVwNpxWShcAxskB3NzZUeAmfNAeo35AiEAmfr6d5RO3zflLBZy0MuCnzw7OyhwggM6PEogdU7fO70"
},
"type": "public-key"
}
}
The publicKeyCredential
field contains the assertion response from the authenticator, which includes the authenticator data, client data JSON, and signature. This information is used by the server to verify the authenticity of the authentication attempt. If the authentication is username-based, the username
field will contain the username provided by the client.
To be able to deserialize the AuthenticationFinishRequest
type from JSON, we need to define a JSON decoder for it. Here is how we can define the decoder:
object AuthenticationFinishRequest {
implicit val decoder: JsonDecoder[AuthenticationFinishRequest] =
JsonDecoder[Json].mapOrFail { o =>
for {
u <-
Right(
o.get(JsonCursor.field("username"))
.toOption
.flatMap(_.as[String].toOption),
)
pkc <- o
.get(JsonCursor.field("publicKeyCredential"))
.map(_.toString())
.map(PublicKeyCredential.parseAssertionResponseJson)
} yield AuthenticationFinishRequest(u, pkc)
}
}
We use the PublicKeyCredential.parseAssertionResponseJson
function from the Yubico WebAuthn library to parse the publicKeyCredential
field from the JSON object.
After receiving the AuthenticationFinishRequest
from the client, the server needs to verify the assertion response. After verifying the response, the server sends back an AuthenticationFinishResponse
object to the client, which indicates whether the authentication was successful. The AuthenticationFinishResponse
type is defined as follows:
case class AuthenticationFinishResponse(
success: Boolean,
username: String,
)
Its JSON codec can be defined straightforwardly as below:
object AuthenticationFinishResponse {
implicit val codec: JsonCodec[AuthenticationFinishResponse] = DeriveJsonCodec.gen
}
Now that we have defined the routes and data types for the authentication flow, let's implement the logic in the WebAuthnService
.
WebAuthn Service
The WebAuthnService
interface is responsible for handling the core logic of the WebAuthn authentication flow. In the registration flow, we implemented the startRegistration
and finishRegistration
methods. Now, for the authentication flow, we need to implement the startAuthentication
and finishAuthentication
methods:
trait WebAuthnService {
def startRegistration(request: RegistrationStartRequest): IO[String, RegistrationStartResponse]
def finishRegistration(request: RegistrationFinishRequest): IO[String, RegistrationFinishResponse]
// new methods
def startAuthentication(request: AuthenticationStartRequest): IO[String, AuthenticationStartResponse]
def finishAuthentication(request: AuthenticationFinishRequest): IO[String, AuthenticationFinishResponse]
}
1. Start Authentication
The startAuthentication
method is responsible for generating the assertion request to be sent to the client. This request contains a challenge and other options required for authentication. The client uses this request to prompt the authenticator to sign the challenge.
To implement this method, we need to perform the following steps:
- Generate an assertion request using the
RelyingParty#startAssertion
method from the Yubico WebAuthn library. This method takes aStartAssertionOptions
object, which contains options for the assertion request, such as the username (if provided), user verification requirement, and timeout, and returns anAssertionRequest
object. TheAssertionRequest
is another name forAuthenticationStartResponse
. So we can directly return it as the response of this method. - Before returning the assertion request, we have to store the generated assertion request in the
pendingAuthentications
map using the challenge as the key. This allows us to retrieve the original assertion request later when verifying the response from the client.
Let's implement the startAuthentication
method:
type Challenge = String
class WebAuthnServiceImpl(
userService: UserService,
pendingRegistrations: Ref[Map[UserHandle, RegistrationStartResponse]],
pendingAuthentications: Ref[Map[Challenge, AuthenticationStartResponse]],
) extends WebAuthnService {
private val relyingPartyIdentity: RelyingPartyIdentity = ???
private val relyingParty: RelyingParty = ???
private def userIdentity(userId: String, username: String): UserIdentity = ???
override def startRegistration(request: RegistrationStartRequest): ZIO[Any, Nothing, RegistrationStartResponse] = ???
override def finishRegistration(
request: RegistrationFinishRequest,
): IO[String, RegistrationFinishResponse] = ???
override def startAuthentication(
request: AuthenticationStartRequest,
): ZIO[Any, Nothing, AuthenticationStartResponse] = {
val assertion = generateAssertionRequest(relyingParty, request.username)
val challenge = assertion.getPublicKeyCredentialRequestOptions.getChallenge.getBase64Url;
pendingAuthentications.update(_.updated(challenge, assertion)).as(assertion)
}
override def finishAuthentication(
request: AuthenticationFinishRequest,
): IO[String, AuthenticationFinishResponse] = ???
}
To generate an assertion request, we need to consider whether a username is provided in the request. If a username is provided, we generate an assertion request specific to that user. If no username is provided, we generate a username-less assertion request suitable for discoverable passkeys:
def generateAssertionRequest(
relyingParty: RelyingParty,
username: Option[String],
timeout: Duration = 1.minutes,
): AssertionRequest = {
// Create assertion request
username match {
case Some(user) if user.nonEmpty =>
// Username-based authentication
relyingParty.startAssertion(
StartAssertionOptions
.builder()
.username(user)
.userVerification(UserVerificationRequirement.REQUIRED)
.timeout(timeout.toMillis)
.build(),
)
case _ =>
// Username-less authentication for discoverable passkeys
relyingParty.startAssertion(
StartAssertionOptions
.builder()
.userVerification(UserVerificationRequirement.REQUIRED)
.timeout(timeout.toMillis)
.build(),
)
}
}
The RelyingParty#startAssertion
method generates an assertion object by performing the following steps:
- Creates a unique challenge.
- If a username is provided, retrieves the user’s credentials and adds their credential IDs to the
allowCredentials
field of thePublicKeyCredentialRequestOptions
. - Sets the user verification requirement to
REQUIRED
, ensuring that the authenticator verifies the user’s identity. - Defines a timeout value for the authentication process.
Finally, the generated assertion request is stored in the pendingAuthentications
map using the challenge as the key. This allows us to retrieve the original assertion request later when verifying the response from the client.
2. Finish Authentication
After the client receives the assertion request from the server, it prompts the authenticator to sign the challenge. The client then sends the assertion response back to the server to finish the authentication process. The finishAuthentication
method is responsible for verifying the response from the client.
To implement the finishAuthentication
method, we need to perform the following steps:
- Retrieve the challenge from the
publicKeyCredential
provided in the request. - Look up the original assertion request from the
pendingAuthentications
map using the challenge. - Call the
RelyingParty#finishAssertion
method to verify the response from the client. This method takes the original assertion request and the public key credential response from the client, performs all the necessary validations, and returns anAssertionResult
that contains the result of the authentication attempt. - Before returning the result of authentication, we have to remove the corresponding pending assertion request from the
pendingAuthentications
map using the challenge as the key. - If the authentication is successful, we return an
AuthenticationFinishResponse
object, which contains a success flag and the username associated with the authenticated credential.
Now, let's implement the finishAuthentication
method in the WebAuthnService
:
class WebAuthnServiceImpl(
userService: UserService,
pendingRegistrations: Ref[Map[UserHandle, RegistrationStartResponse]],
pendingAuthentications: Ref[Map[Challenge, AuthenticationStartResponse]],
) extends WebAuthnService {
private val relyingPartyIdentity: RelyingPartyIdentity = ???
private val relyingParty: RelyingParty = ???
private def userIdentity(userId: String, username: String): UserIdentity = ???
override def startRegistration(
request: RegistrationStartRequest
): ZIO[Any, Nothing, RegistrationStartResponse] = ???
override def finishRegistration(
request: RegistrationFinishRequest,
): IO[String, RegistrationFinishResponse] = ???
override def startAuthentication(
request: AuthenticationStartRequest,
): ZIO[Any, Nothing, AuthenticationStartResponse] = ???
override def finishAuthentication(
request: AuthenticationFinishRequest,
): IO[String, AuthenticationFinishResponse] =
for {
challenge <- ZIO
.succeed(request.publicKeyCredential.getResponse.getClientData.getChallenge.getBase64Url)
assertionRequest <- pendingAuthentications.get
.map(_.get(challenge))
.some
.orElseFail(s"The ${challenge} not found in pending authentication requests!")
assertion =
relyingParty.finishAssertion(
FinishAssertionOptions
.builder()
.request(assertionRequest)
.response(request.publicKeyCredential)
.build(),
)
_ <- pendingAuthentications.update(_.removed(challenge))
} yield AuthenticationFinishResponse(
success = assertion.isSuccess,
username = assertion.getUsername,
)
}
Now that we have implemented the finishAuthentication
method, the authentication flow is complete. The server can now handle both username-less and username-based authentication requests. We are ready to implement the client-side code to interact with these endpoints.
Client-Side Implementation
The client-side authentication form for username-less authentication looks like this:
<button class="passkey-login-btn" onclick="authenticatePasskey()">Sign In with Passkey</button>
For username-based authentication, we can have a simple form like this:
<input type="text" id="username-login" placeholder="Enter your username" />
<button class="username-login-btn" onclick="authenticateWithUsername()">Sign In</button>
Both authenticatePasskey()
and authenticateWithUsername()
functions call a common function performAuthentication
to handle the authentication process. Here is how we can implement these functions:
async function authenticatePasskey() {
await performAuthentication({ username: null, isPasskey: true });
}
async function authenticateWithUsername() {
const username = document.getElementById("username-login").value;
if (!username) {
alert("Please enter your username to sign in")
return;
}
await performAuthentication({ username, isPasskey: false });
}
The performAuthentication
function handles the entire authentication flow:
async function performAuthentication({ username, isPasskey }) {
try {
const requestBody = { username: username };
const startResponse = await fetch("/api/webauthn/authentication/start", {
method: "POST",
headers: { "Content-Type": "application/json" },
body: JSON.stringify(requestBody),
});
if (!startResponse.ok) {
throw new Error("Failed to start authentication");
}
const serverOptions = await startResponse.json();
const publicKeyOptions = serverOptions.publicKeyCredentialRequestOptions;
const assertion = await navigator.credentials.get({
publicKey: PublicKeyCredential.parseRequestOptionsFromJSON(publicKeyOptions),
});
const assertionJSON = assertion.toJSON();
const assertionForServer = {
username: username,
publicKeyCredential: assertionJSON,
};
const finishResponse = await fetch("/api/webauthn/authentication/finish", {
method: "POST",
headers: { "Content-Type": "application/json" },
body: JSON.stringify(assertionForServer),
});
const result = await finishResponse.json();
if (!finishResponse.ok || !result.success) {
throw new Error(result.message || "Failed to finish authentication");
}
// Extract username from result for passkey flow
const authenticatedUser = result.username;
alert("Authentication successful for user: " + authenticatedUser);
return true;
} catch (error) {
alert("Authentication error: " + error);
return false;
}
}
- It sends a
POST
request to the/api/webauthn/authentication/start
endpoint with the username (ornull
for username-less authentication) to start the authentication process. The server responds with the assertion request. - It parses the assertion request and calls the
navigator.credentials.get()
method to prompt the authenticator to sign the challenge. - It serializes the assertion response to JSON format using the
toJSON()
method. - It sends a
POST
request to the/api/webauthn/authentication/finish
endpoint with the username and the serialized assertion to finish the authentication process. - If the authentication is successful, it alerts the user with the authenticated username.
Running Demo
To run the demo application that uses the code we implemented above, execute the following commands in your terminal:
git clone git@github.com:zio/zio-http.git
cd zio-http/
sbt zioHttpExample/runMain example.auth.webauthn.WebAuthnServer
After running these commands, open http://localhost:8080
in your browser. You can now test the registration and authentication flows using both username-based and discoverable passkeys.
Conclusion
In this guide, we explored the full WebAuthn flow: generating challenges, creating credentials, verifying attestations and assertions, and integrating a browser client that supports both username-based and discoverable (passkey) sign-ins. The result is a phishing-resistant authentication system built on public-key cryptography, offering a smoother user experience than one-time codes or SMS.
Using WebAuthn with ZIO HTTP, we implemented a complete passwordless authentication solution that supports both registration and authentication ceremonies, handling discoverable credentials (passkeys) as well as traditional username-based flows.
WebAuthn mitigates the fundamental weaknesses of password-based authentication by leveraging asymmetric cryptography. Private keys never leave the authenticator device, eliminating credential databases as high-value targets. Origin binding prevents phishing, while the challenge–response mechanism ensures each authentication attempt is unique, non-replayable, and tied to the legitimate credential owner.
Finally, when transitioning from development to production, ensure that your WebAuthn setup is correctly configured. WebAuthn requires HTTPS for all communications (with the exception of localhost
during development) and a properly configured relying party identifier (rp.id
) that matches your production domain.