Introducing Passkeys: Registration and Authentication

Introduction

Hello, I’m @Mapdu, a Ruby developer at Money Forward.

I work on Money Forward ID development team, developing a web application that serves as the ID and authentication platform for Money Forward and its group companies.

At Money Forward ID, we have adopted and implemented passkeys for nearly two years, making them the primary authentication method, second only to passwords.

With Apple introducing and supporting the Automatic Passkey Upgrades feature, allowing users to easily transition from traditional passwords to passkeys, and Chrome supporting the Signal API, which ensures passkeys on passkey providers remain consistent with public key credentials on relying party servers, it’s clear that passkeys are becoming a trend and will gradually replace traditional passwords in the future.

In today’s topic, let’s explore what passkeys are and why they have the potential to replace traditional passwords.

First, let’s examine what the problem with passwords is.

The Problem With Passwords

For decades, passwords have been the first line of defense in securing online accounts. However, passwords are easy to misuse due to poor practices like password reuse and weak credential policies, presenting major security challenges.

  • Weak Passwords : Users often create passwords that are easy to guess or reuse them across multiple accounts.
  • Phishing Attacks : Attackers use social engineering to trick users into revealing passwords.
  • Credential Stuffing : Attackers exploit reused passwords across multiple sites.
  • Database Leaks : A single leak can expose millions of user passwords, leading to widespread exploitation.

The traditional solution has been to layer additional security measures — like multi-factor authentication (MFA) on top of passwords. However, popular MFA methods like SMS codes and email-based verifications remain inconvenient and phishable.

Let’s go to Passkeys!

What are Passkeys?

Passkeys are a safer and easier alternative to passwords. They allow users to sign in using the same method they use to unlock their device — such as biometrics, a PIN, or a pattern.

Unlike passwords, passkeys are resistant to phishing, inherently secure, user-friendly, and easier to use.

Key characteristics of passkeys include:

  • Phishing resistance : No passwords to steal or misuse.
  • Ease of use : Users unlock passkeys the same way they unlock their devices.
  • Cross-platform compatibility : Passkeys work across all devices and platforms.

With passkeys, signing in becomes as simple as unlocking your device. No more memorizing or typing passwords —> just secure and fast access.

Why is Passkey secure?

Advertisements always sound appealing, like sweet words whispered into our ears. However, we cannot trust and use everything without verifying it first.

To avoid using passkeys blindly and without a deep understanding, take a look at the following diagram!

This is the concept of Public and Private Keys.

Imagine you’re trying to access a remote server (via SSH). Instead of sharing a password, you use a key pair:

  • Private Key: This is your secret. It’s stored securely on your personal device and never shared.
  • Public Key: This is shared with the server or website. It acts like a public lock that only your private key can unlock.

When you attempt to connect:

  1. The client generates a challenge (a random string), signs it using your private key, and then sends the signed challenge to the server.
  2. When the server receives the connection request, it verifies the signed challenge using the public key.
  3. If the signed challenge is valid, the connection is established.

This mechanism is called key-based authentication, and the way Passkeys work is similar: the application (Relying Party) only stores the public key and allows login only if the user can prove they possess the private key.

  • Impossible to Guess the Private Key:
    • Just like SSH, even if someone has the public key, they cannot deduce or recreate the private key.
  • No Secret Exposed During Login:
    • With passwords, you must send your secret (the password) to the server. If intercepted, it can be stolen. With public key authentication, the private key never leaves your device. Only the response to the challenge is sent.
  • Against Phishing:
    • Phishing relies on tricking users into entering passwords on fake websites. With Passkeys, this is mitigated because the challenge-response process is tied to the actual website or server. A fake site cannot generate a valid challenge.
  • Etc.

All of the above factors make using passkeys safer than traditional passwords.

Passkeys in practice

Now that we understand why passkeys are secure for users, let’s explore how to implement them in practice.

For this demo, I am using:

  • Ruby and JavaScript as the primary programming languages
  • WebAuthn JSON, a small WebAuthn API wrapper for client-side use
  • WebAuthn Ruby, a library for implementing WebAuthn on the server-side

You can try the passkey demo at https://mapdu.dev.

Source code: https://github.com/NgocHai220998/sample_idp

Passkey Registration

In short, the process of registering a passkey is simple: the authenticator generates a key pair (public and private) and sends the public key to the application for storage.

There are several scenarios for registering passkeys, but the two most common are:

  • Registering a new account using a passkey.
  • Adding a passkey to an existing account.

For this post, let’s focus on the second scenario: a user who already has an account and is logged into the system. Refer to the following UML diagram:

Here are the key components of this registration flow.

  • User : The individual registering a passkey for their account.
  • Client : The relying party client application (e.g., web browser, mobile app) that interacts with the user and server.
  • Authenticator : The user’s device (e.g., phone, hardware security key) that creates and securely stores the passkey.
  • Server : The Relying Party server managing the account and validating the registration.

Note that the Browser sits between the client and the Authenticator but is not explicitly shown in this diagram.

STEP 1 – Invoke the API to retrieve the PublicKeyCredentialCreationOptions

The user initiates the passkey registration process by clicking the corresponding button in the user interface, the client application must invoke the server API to retrieve the PublicKeyCredentialCreationOptions. This object contains the information required to create a new passkey.

<!-- passkey-registration.html -->

<script name="Resgister a new Passkey">
  document.addEventListener("DOMContentLoaded", () => {
    const registerBtn = document.querySelector("#register-passkey-btn");

    registerBtn.addEventListener("click", async (event) => {
      // Get the registration options
      const options = await fetch("/webauthn/credentials/options", {
        method: "POST",
        headers
      });
    });
  });
</script>

When the server receives the request, it generates a new PublicKeyCredentialCreationOptions object includes:

  • Challenge : A randomly generated value to prevent replay attacks.
  • User Information : Information about the user registering the passkey (e.g., username, display name).
  • Relying Party Info : Information about the server requesting the registration (e.g., name).

Refer: Options for Credential Creation

# webauthn_credentials_controller#options

def options
  registration_options = WebAuthn::Credential.options_for_create(
    user:,
    rp:,
    challenge:
  )

  session[:webauthn_credential_attestation] = {
    webauthn_id:,
    challenge:
  }

  render status: :ok, json: { publicKey: registration_options }
end

Refer: WebAuthn ruby server library

The server stores some data (e.g. challenge) in the session for later validation, then sends the PublicKeyCredentialCreationOptions object back to the client.

STEP 2Invoke Credential Management API to create AttestationObject.

After receiving the PublicKeyCredentialCreationOptions, the client application invokes the Credential Management API to create a new credential.

<!-- passkey-registration.html -->

<script name="Resgister Passkey">
  document.addEventListener("DOMContentLoaded", () => {
    const registerBtn = document.querySelector("#register-passkey-btn");

    registerBtn.addEventListener("click", async (event) => {
      const options = await ...
      
      // Create a new credential
      const credential = await webauthn.create(
        webauthn.parseCreationOptionsFromJSON(options)
      );
    });
  });
</script>

Refer: A small WebAuthn API wrapper – Javascript library

At this step, the user is prompted to verify their identity by performing an action, such as touching a fingerprint sensor or entering a PIN. Once the user successfully verifies, the client receives an AttestationObject from the Authenticator. The client then sends the AuthenticatorAttestationResponse to the server for validation.

Refer: Information About Public Key Credential

STEP 3Invoke the API to send the AuthenticatorAttestationResponse back to server.

Finally, client sends the AuthenticatorAttestationResponse to server for validation.

<!-- passkey-registration.html -->

<script name="Resgister Passkey">
  document.addEventListener("DOMContentLoaded", () => {
    const registerBtn = document.querySelector("#register-passkey-btn");

    registerBtn.addEventListener("click", async (event) => {
      const options = await ...
      const credential = await WebauthnObject.webauthn.create(
        WebauthnObject.webauthn.parseCreationOptionsFromJSON(options)
      );

      // Send the AuthenticatorAttestationResponse to the server
      await fetch("/webauthn/credentials", {
        method: "POST",
        headers,
        body: JSON.stringify(credential.toJSON()),
      });
    });
  });
</script>

At this stage, server must validate the response to ensure the attestation is authentic and valid.

  • Validates the AttestationObject to ensure it is genuine and from a trusted Authenticator.
  • Checks that the challenge matches the one it originally sent to prevent replay attacks.
  • If everything is valid:
    • Stores the credential ID and public key in the user’s record in the database.
    • Marks the registration as successful.
# webauthn_credentials_controller#create

def create
  verify_challenge!

  @public_key_credential = WebAuthn::Credential.from_create(credential) if credential

  WebauthnCredential.create!(
    user: current_user,
    public_key: @public_key_credential.public_key,
    credential_id: @public_key_credential.id,
    sign_count: @public_key_credential.sign_count
  )

  render json: {}, status: :ok
end

Passkey Authentication

Now that the user has registered a passkey, they can use it to authenticate. The authentication flow is similar to the registration flow, but with a few key differences.

Let’s take a look at the UML diagram for the authentication flow:

STEP 1Invoke the API to retrieve the PublicKeyCredentialRequestOptions.

When user access to the sign-in page, the client application must invoke the server API to retrieve the PublicKeyCredentialRequestOptions automatically.

This is called Passkey Autofill. When triggered via the Sign in with Passkey button, the behavior is similar.

<!-- sign_in.html -->

<input type="email" id="email" autocomplete="email webauthn">
...


<script name="Webauthn Assertion (Authentication)">
  document.addEventListener("DOMContentLoaded", async () => {
    const options = await fetch('/webauthn/assertion/options', {
      method: 'POST',
      headers,
    });
  })
</script>

When the server receives the request, it generates a new PublicKeyCredentialRequestOptions object includes:

  • Challenge : A randomly generated value to prevent replay attacks.
# webauthn_assertions_controller#options

def options
  authentication_options = WebAuthn::Credential.options_for_get(
    challenge:,
  )

  session[:current_authentication] = { # Store the challenge for later validation
    allow: authentication_options.allow,
    authentication_challenge: authentication_options.challenge
  }

  render status: :ok, json: { publicKey: authentication_options }
end

The server stores some data (e.g. challenge) in the session for later validation, then sends the PublicKeyCredentialRequestOptions object back to the client.

Refer: Options for Credential Request

STEP 2Invoke Credential Management API to create AuthenticatorAssertionObject.

After receiving the PublicKeyCredentialRequestOptions, the client application invokes the Credential Management API to create a new assertion.

<!-- sign_in.html -->

<input type="email" id="email" autocomplete="email webauthn">
...


<script name="Webauthn Assertion (Authentication)">
  document.addEventListener("DOMContentLoaded", async () => {
    const options = await ...

    // Create a new assertion
    const authenticatorResponse = await webauthn.get(options);
  })
</script>

At this step, the client displays a list of credentials that the user can use for authentication. This occurs when the user clicks on an input field with the attribute autocomplete='webauthn'.

When user selects a credential, the client invokes the Credential Management API to create a new assertion. The user is prompted to verify their identity by performing an action, such as touching a fingerprint sensor or entering a PIN.

STEP 3Invoke the API to send AuthenticatorAssertionResponse back to server.

Once the user successfully verifies, client receives an AuthenticatorAssertionResponse from Authenticator. Client sends the AuthenticatorAssertionResponse to server for validation.

<!-- sign_in.html -->

<input type="email" id="email" autocomplete="email webauthn">
...


<script name="Webauthn Assertion (Authentication)">
  document.addEventListener("DOMContentLoaded", async () => {
    const options = await ...
    const authenticatorResponse = await webauthn.get(options);

    // Send the AuthenticatorAssertionResponse to the server
    const response = await fetch('/webauthn/assertion', {
      method: 'POST',
      headers,
      body: JSON.stringify({
        authenticatorResponse: authenticatorResponse
      })
    });
    // Redirect to the next page after successful authentication
    window.location.href = response.redirectPath;
  })
</script>

At this stage, server must validate the response to ensure the assertion is authentic and valid.

# webauthn_assertions_controller#create

def create
  validate!

  user = authenticated_credential.user
  sign_in(user, method: :passkey)

  render status: :ok, json: { redirectPath: root_path }
end

Conclusion

Passkeys demonstrate a high level of security and potential as a replacement for traditional passwords in the future. However, making passkeys widely adopted and educating traditional users on what passkeys are and how secure they can be remains a significant challenge that requires time and effort.

But I believe, with the efforts of major platforms like Apple, Google, and Microsoft, especially through the FIDO Alliance, the transition from traditional passwords to passkeys will become smoother in the future.

In the next article, we will discuss Automatic Passkey Upgrades and the Signal API, which help users transition effortlessly and ensuring passkeys on passkey providers remain consistent with public key credentials on relying party servers.

See you!

Published-date