Passkey Sign In with Elixir and Phoenix

Passkeys are a replacement for passwords that use public/private cryptographic key pairs for login in a way that can be more user-friendly and resistant to a number of kinds of attacks. Let’s implement account registration and login with passkeys in a simple Phoenix web app.

This work is heavily based on this article by Adam Langley, which provides a great deal of information about implementing passkeys. My goal here is to fill in some more of the details, and provide some Elixir-specific information. As with Adam’s article, I’m not going to use any WebAuthn libraries (even though that may be advisable from a security/maintenance perspective) since I think it’s interesting and helpful to understand how things actually work.

Providing an exhaustive, production-ready implementation is a non-goal of this post. I’m going to make some slightly odd decisions for pedagogical reasons, and leave some things incomplete. That said, I’ll try to note when I’m doing so.

To start, I’m using the default Phoenix template app (less Tailwind) in which I’ve also generated the default, controller-based authentication system. Some parts of the password-specific stuff have been stripped out altogether, others will get changed to fit with the passkey authentication setup we’re going to build.

Database schema

The first thing we’ll need is a backend schema for passkeys. The only data we need to store for a particular passkey is a unique identifier, and the public key itself. We also want users to be able to have multiple passkeys associated with their account (since they may want to login from multiple platforms that don’t cross-sync passkeys), so the users schema will have a one-to-many relationship with the passkeys.

The actual model I’ll call UserCredential, since that’s closer to what the WebAuthn spec calls them. Here’s the schema, it’s pretty simple:

defmodule PhoenixPasskeys.Accounts.UserCredential do
  use Ecto.Schema
  import Ecto.Changeset

  @primary_key {:id, :binary, []}

  schema "users_credentials" do
    # DER-encoded Subject Public Key Info: https://datatracker.ietf.org/doc/html/rfc5280#section-4.1.2.7
    field :public_key_spki, :binary
    belongs_to :user, PhoenixPasskeys.Accounts.User
    timestamps()
  end

  def changeset(credential, attrs) do
    credential
    |> cast(attrs, [:id, :public_key_spki, :user_id])
  end
end

There are a couple things to note here:

  1. First, we’re explicitly specifying that the primary key is a binary, since that’s what the WebAuthn API provides as the credential ID.
  2. The public_key_spki field contains the data for the public key itself and the algorithm being used. We don’t care about the specific format, though, since we don’t have to parse it ourselves.

To the generated User schema, I also added the has_many side of the relationship. There’s also a migration to create the credentials table. I won’t show it here, since it’s exactly what you’d expect—just make sure the id column is a binary.

Registration JavaScript

WebAuthn, being a modern web API, is a JavaScript API. This is a distinct disadvantage, if your users expect to be able to login from JavaScript-less browsers. Nonetheless, we proceed with the JS. Here’s the first bit:

document.addEventListener("DOMContentLoaded", () => {
    const registrationForm = document.getElementById("registration-form");
    if (registrationForm) {
        registrationForm.addEventListener("submit", (event) => {
            event.preventDefault();
            registerWebAuthnAccount(registrationForm);
        });
    }
});

If we find the registration <form> element (note that I’ve added an ID (and also removed the password field)), we add a submit listener to it which prevents the form submission and instead calls the registerWebAuthnAccount function we’ll create next.

Before we get there, we’ll also write a brief helper function to check whether passkeys are actually available:

async function supportsPasskeys() {
    if (!window.PublicKeyCredential || !PublicKeyCredential.isConditionalMediationAvailable) {
		return false;
	}
	const [conditional, userVerifiying] = await Promise.all([
		PublicKeyCredential.isConditionalMediationAvailable(),
		PublicKeyCredential.isUserVerifyingPlatformAuthenticatorAvailable(),
	]);
	return conditional && userVerifiying;
}

The conditional mediation check establishes that WebAuthn conditional UI is available. This is what we’ll use for login, and is part of what makes using passkeys a nice, slick experience. Conditional UI will let us start a request for a passkey that doesn’t present anything until the user interacts with the browser’s passkey autofill UI.

The user-verifying platform authenticator check establishes that, well, there is a user-verifying platform authenticator. The platform authenticator part means an authenticator that’s part of the user’s device, not removable, and the user-verifying part means that the authenticator verifies the presence of the user (such as via biometrics).

In the registerWebAuthnAccount function, the first thing we need to do is check that both these conditions are met and passkeys are supported by the browser. If not, we’ll just bail out and registration won’t be possible.

async function registerWebAuthnAccount(form) {
    if (!(await supportsPasskeys())) {
        return;
    }
}

Next, we’ll setup a big ol’ options object that we’ll pass to the WebAuthn API to specify what sort of credential we want. There’s going to be a whole lot of stuff here, so lets take it one piece at a time.

async function registerWebAuthnAccount(form) {
    // ...
    const createOptions = {
        publicKey: {
			rp: {
				id: "localhost",
				name: "Phoenix Passkeys",
			}, 
        },
    };
}

The RP is the Relying Party—that is, us, the party which relies on the credentials for authentication. The ID is the domain of the RP—just localhost for this example, though in reality you’d need to use your actual domain in production. The name is just a user-facing name for the RP.

async function registerWebAuthnAccount(form) {
    // ...
    const createOptions = {
        publicKey: {
            // ...
            user: {
                id: generateUserID(),
                name: form["user[email]"].value,
                displayName: "",
            },
        },
    };
}

Next up is some info about the user who the credential is for. The user’s “name” will just be the email that they entered in the form. The displayName value is required, per the spec, but we don’t have any other information so we just leave it blank and let the browser display the name only. The ID is where this gets a little weird, since we’re just generating a random 64-byte value:

function generateUserID() {
	const userID = new Uint8Array(64);
	crypto.getRandomValues(userID);
	return userID;
}

If you were adding a passkey to an existing user account, you could use a server-specified value for the user ID and the browser would replace any existing credentials with the same user ID and RP ID. But, since the user is registering a new account at this point, we assume they don’t want to do that (and, moreover, we don’t have any ID we can use yet). The user ID will also be returned upon a successful login, which would let us look up the user that’s logging in. However, since we’re only going to allow a credential to belong to a single user, we can use the credential’s ID to uniquely determine the user.

Next up, we specify the types of public keys that we’ll accept. We’re going to support Ed25519, ES256, and RS256, since those are recommended by the WebAuthn spec:

async function registerWebAuthnAccount(form) {
    // ...
    const createOptions = {
        publicKey: {
            // ...
            pubKeyCredParams: [
                {type: "public-key", alg: -8}, // Ed25519
                {type: "public-key", alg: -7}, // ES256
                {type: "public-key", alg: -257}, // RS256
            ],
        },
    };
}

During the login process, the server will generate a challenge value that the client will sign and the server will verify was signed with the user’s private key. The spec also requires that we provide a challenge when creating a credential, but we’re not going to use it for anything (since the user is just now creating the credential, we have nothing trusted that we can verify the initial challenge against), so we just provide an empty value:

async function registerWebAuthnAccount(form) {
    // ...
    const createOptions = {
        publicKey: {
            // ...
            challenge: new Uint8Array(),
        },
    };
}

Lastly, we give our requirements for authenticators that we want to use:

async function registerWebAuthnAccount(form) {
    // ...
    const createOptions = {
        publicKey: {
            // ...
            authenticatorSelection: {
                authenticatorAttachment: "platform",
                requireResidentKey: true,
            },
        },
    };
}

We want only the platform authenticator, not anything else, like removable security keys. We also specify that we want a resident key. This usage of “resident” is deprecated terminology that’s enshrined in the API. Really it means we want a discoverable credential, so that the authenticator will surface it during the login process without us having to request the credential by ID. This is important to note, since it’s what prevents needing a separate username entry step.

Now that we (finally) have all the configuration options in place, we can actually proceed with the credential creation. We pass the options to navigator.credentials.create to actually create the WebAuthn credential. If that fails, we’ll just take the easy way out and alert to inform the user (in an actual service, you’d probably want better error handling).

async function registerWebAuthnAccount(form) {
    // ...
    const credential = await navigator.credentials.create(createOptions);
    if (!credential) {
        alert("Could not create credential");
        return
    } 
}

From the credential we get back, we need a few pieces of information. First is the decoded client data, which is an object that contains information about the credential creation request that occurred.

async function registerWebAuthnAccount(form) {
    // ...
    const clientDataJSON = new TextDecoder().decode(credential.response.clientDataJSON);
    const clientData = JSON.parse(clientDataJSON);
}

The clientDataJSON field of the response object contains the JSON-serialized object in an ArrayBuffer, so we decode that to text and then parse the JSON. With the decoded object, we do a consistency check with a couple pieces of data: the type of the request, whether or not it was cross-origin, and the actual origin being used.

async function registerWebAuthnAccount(form) {
    // ...
    if (clientData.type !== "webauthn.create" || (("crossOrigin" in clientData) && clientData.crossOrigin) || clientData.origin !== "http://localhost:4000") {
        alert("Invalid credential");
        return;
    } 
}

If it was not a creation request, the request was cross-origin, or the origin doesn’t match, we bail out. Note that in production, the origin should be checked against the actual production origin, not localhost. And again, in reality you’d want better error handling than just an alert.

Next, we need to get the authenticator data which is encoded in a binary format and pull a few pieces of data out of it. You can see the full format of the authenticator data in the spec, but the parts we’re interested in are the backed-up state and the credential ID, which is part of the attested credential data.

async function registerWebAuthnAccount(form) {
    // ...
    const authenticatorData = new Uint8Array(credential.response.getAuthenticatorData());
    const backedUp = (authenticatorData[32] >> 4) & 1;
    const idLength = (authenticatorData[53] << 8) | authenticatorData[54];
    const id = authenticatorData.slice(55, 55 + idLength);
}

We get the backed-up bit from the flags byte at offset 32. Then we get the length of the credential ID, which is encoded as a big-endian, 16-bit integer at bytes 53 and 54. The ID itself immediately follows the length, thus starting at byte 55.

Before proceeding, we check that the backed-up bit is set, indicating that the credential is backed-up and safe from the user losing the current device. If it’s not, we won’t let the user register with this passkey. I choose to do this since it’s recommended by Adam Langley’s blog post, but whether it’s actually necessary may depend on your specific circumstances.

async function registerWebAuthnAccount(form) {
    // ...
    if (backedUp !== 1) {
        alert("Can't register with non backed-up credential");
        return;
    } 
}

The last piece of data we need out of the credential is the actual public key:

async function registerWebAuthnAccount(form) {
    // ...
    const publicKey = credential.response.getPublicKey();
}

And with all that in place, we can actually initiate the registration. We’ll assemble a form data payload with all of the requisite values:

async function registerWebAuthnAccount(form) {
    // ...
    const body = new FormData();
    body.append("_csrf_token", form._csrf_token.value);
    body.append("email", form["user[email]"].value);
    body.append("credential_id", arrayBufferToBase64(id));
    body.append("public_key_spki", arrayBufferToBase64(publicKey)); 
}

We’ll use the body in a POST request to the registration endpoint. The response we get back will contain a status value to indicate whether the request was successful or not. If it was successful, the backend will have set the session cookie, and we can redirect to the home page and the new user will be logged in. If registration failed, we’ll alert the user.

async function registerWebAuthnAccount(form) {
    // ...
    const resp = await fetch(form.action, {
        method: "POST",
        body,
    });
    const respJSON = await resp.json();
    if (respJSON.status === "ok") {
        window.location = "/";
    } else {
        alert("Registration failed");
    }
}

And with that, we’re done with JavaScript (for now) and can move on to the backend part of registering an account.

Creating a user

In the UserRegistrationController module that comes with the Phoenix auth template, we’ll change the create function. By default, it registers the user using the parameters from the signup form and then redirects to the homepage. Instead, we’re going to register using the passkey we created on the client and then respond with the JSON that our JavaScript is expecting.

The first thing we need to do is extract the values that were sent by the frontend and decode the base 64-encoded ones.

defmodule PhoenixPasskeysWeb.UserRegistrationController do
  def create(conn, %{
        "email" => email,
        "credential_id" => credential_id,
        "public_key_spki" => public_key_spki
      }) do
    credential_id = Base.decode64!(credential_id)
    public_key_spki = Base.decode64!(public_key_spki)
  end
end

Those get passed to the Accounts.register_user function, which we’ll update shortly to handle. If account creation succeeded, we’ll still send the confirmation email as the existing code did. After that, instead of redirecting, we’ll log the user in by setting the session cookie and then respond with the “ok” status for the frontend. If account creation fails, we’ll just respond with the “error” status so the frontend can alert the user.

defmodule PhoenixPasskeysWeb.UserRegistrationController do
  def create(...) do
    # ...
    case Accounts.register_user(email, credential_id, public_key_spki) do
      {:ok, user} ->
        # Send confirmation email...

        conn
        |> UserAuth.log_in_user_without_redirect(user)
        |> json(%{status: :ok})

      {:error, _changeset} ->
        json(conn, %{status: :error})
    end
  end
end

Let’s update the register_user function. Instead of just creating a changeset for the user and then inserting it, we need to also create the authenticator. To avoid potentially leaving things in a broken state, we wrap both of these in a database transaction.

defmodule PhoenixPasskeys.Accounts do
  def register_user(email, credential_id, public_key_spki) do
    Repo.transaction(fn ->
      user =
        %User{}
        |> User.registration_changeset(%{email: email})
        |> Repo.insert()
        |> case do
          {:ok, user} -> user
          {:error, changeset} -> Repo.rollback(changeset)
        end
    end)
  end
end

First, we create a user with the given email. If the user creation fails, we abort and rollback the transaction. Then, we can create a credential belonging to the new user with the credential ID and public key we received from the client. As with the user, if creating the credential fails, we rollback the transaction.

defmodule PhoenixPasskeys.Accounts do
  def register_user(email, credential_id, public_key_spki) do
    Repo.transaction(fn ->
      # ...
      %UserCredential{}
      |> UserCredential.changeset(%{
        id: credential_id,
        public_key_spki: public_key_spki,
        user_id: user.id
      })
      |> Repo.insert()
      |> case do
        {:ok, _credential} -> nil
        {:error, changeset} -> Repo.rollback(changeset)
      end      
    end)
  end
end

We don’t need to do anything with the newly created credential, so we can just ignore it once it’s been created.

And finally, from the transaction function, we return the user:

defmodule PhoenixPasskeys.Accounts do
  def register_user(email, credential_id, public_key_spki) do
    Repo.transaction(fn ->
      # ...
      user
    end)
  end
end

The last thing we need to to complete the registration flow is to set the new user’s session cookie so that they’re logged in immediately. The UserAuth module that’s generated as part of the Phoenix auth template has a log_in_user function that does exactly this. But it also redirects the connection to another endpoint. We don’t want to do that, since we’re sending a JSON response, so I’ve split the function into two: one that only sets the session, and the existing function that sets the session and then redirects.

defmodule PhoenixPasskeysWeb.UserAuth do
  def log_in_user(conn, user, params \\ %{}) do
    user_return_to = get_session(conn, :user_return_to)
    conn
    |> log_in_user_without_redirect(user, params)
    |> redirect(to: user_return_to || signed_in_path(conn))
  end

  def log_in_user_without_redirect(conn, user, params \\ %{}) do
    token = Accounts.generate_user_session_token(user)
    conn
    |> renew_session()
    |> put_token_in_session(token)
    |> maybe_write_remember_me_cookie(token, params)
  end
end

And with that, everything is in place and you can now create an account with a passkey!

Login form

Now that the user’s got an account, they need to be able to login with it. That means once again interacting with the WebAuthn API and writing a bunch of JavaScript. But before we get there, we need some slight changes to the backend.

In the HTML for the login page, we’ll add an ID to the <form> element so that we can find it from JS. We’ll also remove the password field, which is obviously no longer necessary. Lastly, but certainly not least, we need to send a challenge to the client.

The challenge is a value that the user’s device will cryptographically sign with their private key. The result will get sent back to the server, and we’ll verify it against the public key we have stored thus authenticating them. We’ll send the challenge just in a hidden form field:

<input type="hidden" id="challenge" value={@webauthn_challenge} />

In the session controller, we’ll need to generate and assign the challenge to the connection.

defmodule PhoenixPasskeysWeb.UserSessionController do
  def new(conn, _params) do
    conn
    |> put_webauthn_challenge()
    |> render(:new, error_message: nil)
  end
end

WebAuthn expects the challenge to be a value up to 64 bytes long, so we’ll use the Erlang crypto module to generate one of that length. The value is encoded as URL-safe base 64 (the same as normal base 64, but with dash and underscore rather than plus and slash) without padding. We encode it this way since that’s the format in which it will later be returned as part of the clientDataJSON, so when we extract that value we can directly compare it to the challenge value we generated.

defmodule PhoenixPasskeysWeb.UserSessionController do
  defp put_webauthn_challenge(conn) do
    challenge =
      :crypto.strong_rand_bytes(64)
      |> Base.url_encode64(padding: false)

    conn
    |> put_session(:webauthn_challenge, challenge)
    |> assign(:webauthn_challenge, challenge)
  end
end

Note that the challenge string is also stored in the session, so that we can later check that the challenge that the client signed matches the challenge we generated. It’s safe to store this in the session, even though it’s sent to the client, because the cookie is encrypted and signed so the client can’t tamper with it.

Login JavaScript

With the first part of the backend changes taken care of, it’s time for more JavaScript, baby!

We’ll follow a similar outline to the registration setup (and the same caveat applies about error handling).

document.addEventListener("DOMContentLoaded", () => {
    // ...
    const loginForm = document.getElementById("login-form");
	if (loginForm) { 
        loginWebAuthnAccount(loginForm);
    }
});

async function loginWebAuthnAccount(loginForm) {
    if (!(await supportsPasskeys())) {
        return;
    }
}

The first thing we need to do is grab the challenge from the hidden form field, then we can construct the options object for getting the credential (which is, thankfully, much simpler than for creation).

async function loginWebAuthnAccount(loginForm) {
    // ...
    const challenge = loginForm.challenge.value;
    const getOptions = {
        mediation: "conditional",
        publicKey: {
            challenge: base64URLToArrayBuffer(challenge),
            rpId: "localhost",
        },
    };
}

In the options, we specify that we want conditional mediation. As noted before, this means that the browser won’t display any UI, except for autofill, for this credential request until the user accepts the autofill suggestion. In the public key options, we also give the decoded challenge value and specify the our Relying Party ID (again, this would need to be the actual domain in production).

Now, we can actually make the credential request and then, if we get a credential back, encode and send all the values to the backend. We need to send the ID of the credential, so that the backend can find its public key and the corresponding user. We also need the client data JSON, which we send as text decoded from the ArrayBuffer it’s returned as. We also need to send the authenticator data as well as the signature itself.

async function loginWebAuthnAccount(loginForm) {
    // ...
    const credential = await navigator.credentials.get(getOptions);
    if (!credential) {
        alert("Could not get credential");
        return;
    }

    const clientDataJSON = new TextDecoder().decode(credential.response.clientDataJSON);

    const body = new FormData();
    body.append("_csrf_token", loginForm._csrf_token.value);
    body.append("raw_id", arrayBufferToBase64(credential.rawId));
    body.append("client_data_json", clientDataJSON);
    body.append("authenticator_data", arrayBufferToBase64(credential.response.authenticatorData));
    body.append("signature", arrayBufferToBase64(credential.response.signature));
}

We can then send a request to the login endpoint. If the backend request fails (or if we failed to get the credential) we just alert the user (again, you’d probably want something better in reality). If the login attempt was successful, the server will have set the session cookie, and so we can just redirect to the homepage and the user will be logged in.

async function loginWebAuthnAccount(loginForm) {
    // ...
    const resp = await fetch("/users/log_in", {
        method: "POST",
        body,
    });
    const respJSON = await resp.json();
    if (respJSON?.status === "ok") {
        window.location = "/";
    } else {
        alert("Login failed");
    }
}

With that in place, let’s move on to the backend half of the login request.

Validating a login attempt

As with signup, we’ll modify the existing log in endpoint to actually validate the WebAuthn login attempt.

The first step is extracting all of the information the frontend provides in the params and decoding it:

defmodule PhoenixPasskeysWeb.UserSessionController do
  def create(conn, params) do
    id = params |> Map.get("raw_id") |> Base.decode64!()
    authenticator_data = params |> Map.get("authenticator_data") |> Base.decode64!()
    client_data_json_str = params |> Map.get("client_data_json")
    signature = params |> Map.get("signature") |> Base.decode64!()
  end
end

Next, we’re going to validate all of the information we got from the client. Before we get to that, there are a handful of helper functions we’ll use. First, looking up a credential by its ID:

defmodule PhoenixPasskeys.Accounts do
  def get_credential(id) do
    Repo.get(UserCredential, id)
    |> Repo.preload(:user)
  end
end

Next, a function in the controller that verifies the signature against the provided data:

defmodule PhoenixPasskeysWeb.UserSessionController do
  defp verify_signature(credential, client_data_json_str, authenticator_data, signature) do
    with {:ok, pubkey} <- X509.PublicKey.from_der(credential.public_key_spki),
         client_data_json_hash <- :crypto.hash(:sha256, client_data_json_str),
         signed_message <- authenticator_data <> client_data_json_hash,
         true <- :public_key.verify(signed_message, :sha256, signature, pubkey) do
      true
    else
      _ ->
        false
    end
  end
end

X509 comes from the x509 package, which is the only third-party piece of code we’re using. It’s a fairly thin wrapper around the Erlang public_key and crypto modules, and mostly serves to save me from having to deal with Erlang records in my code. Its from_der helper function is used to parse the public key from the encoded format.

Next, we hash the client data JSON and append that hash to the authenticator data from the client. This value is what should match the signature using the public key we’ve got, so finally we check that. If all these steps succeeded, we return true, and false otherwise.

The last helper function will receive the decoded client data make sure it’s got all of the values that we expect. If the crossOrigin value is present and is not false, the client data is invalid and the login attempt will be rejected.

defmodule PhoenixPasskeysWeb.UserSessionController do
  defp check_client_data_json(%{"crossOrigin" => crossOrigin}) when crossOrigin != false do
    false
  end
end

Otherwise, we check that the data has the expected type and origin, and we extract the challenge value (note that we’re checking the origin again here, and this would need to change in production):

defmodule PhoenixPasskeysWeb.UserSessionController do
  defp check_client_data_json(%{
         "type" => "webauthn.get",
         "challenge" => challenge,
         "origin" => "http://localhost:4000"
       }) do
    {:ok, challenge}
  end 
end

And lastly, if neither of the previous patterns matched, the client data fails validation:

defmodule PhoenixPasskeysWeb.UserSessionController do
  defp check_client_data_json(_), do: false
end

Now, let’s put all those parts together and validate the login attempt.

defmodule PhoenixPasskeysWeb.UserSessionController do
  def create(conn, params) do
    # ...
    with credential when not is_nil(credential) <- Accounts.get_credential(id),
         true <- verify_signature(credential, client_data_json_str, authenticator_data, signature),
         {:ok, client_data_json} <- Jason.decode(client_data_json_str),
         {:ok, challenge} <- check_client_data_json(client_data_json),
         true <- challenge == get_session(conn, :webauthn_challenge),
         true <- :binary.part(authenticator_data, 0, 32) == :crypto.hash(:sha256, "localhost"),
         true <- (:binary.at(authenticator_data, 32) &&& 1) == 1 do
      conn
      |> delete_session(:webauthn_challenge)
      |> UserAuth.log_in_user_without_redirect(credential.user)
      |> json(%{status: :ok})
    else
      _ ->
        json(conn, %{status: :error})
    end
  end
end

Here’s everything that we’re doing:

  1. Lookup the credential with the given ID.
  2. Use the public key we had stored to verify the signature on the authenticator data and client data JSON.
  3. Decode the client data JSON.
  4. Check that all the values in the client data are what we expect, and extract the challenge that was signed.
  5. Ensure the challenge that the user signed matches what we previously generated.
  6. Extract the hash of the origin from the authenticator data and ensure it matches our origin (this would not be localhost in production).
  7. Check that the authenticator data has the user presence bit set (indicating that a person was actually present on the client’s end).

If all of those steps succeeded, we remove the old challenge value from the session (since it’s no longer needed), actually log the user in, and then respond with the “ok” status that the JavaScript is expecting. If any step failed, we’ll respond with the “error” status and the frontend will alert the user.

Since there’s a lot going on here, it’s worth being clear about what exactly in this process lets us authenticate and prove that the user is who they claim to be. Since the signature verification step is using the public key that we stored during the registration process, we know that anyone that can produce a valid signature using that public key must be the user (or at any rate, have their private key). The value that they’re signing is, essentially, the challenge: a securely generated random value. The user isn’t directly signing the challenge, but this is still safe, since the challenge value is included in the client data JSON, that hash of which is included in the signed message.

So: the challenge value that was signed by the user must be in the client data, and the challenge value in the client data must be the one we generated. Given that, we know that the user whose key was used to sign the message is the one trying to log in now. That we’re verifying with the stored public key prevents an attacker from using an arbitrary key to sign the login attempt. And that the signed challenge matches the challenge the server generated means an attacker can’t reuse a previous response to login (a replay attack).

At long last, we finally have the ability to log in to our application using a passkey. Only a few minor things to go, so let’s forge ahead.

Handling login if the user enters an email

Although we’re presenting the conditional UI, there’s nothing preventing the user from typing their email into the field and then clicking “Sign in,” so we should probably handle that to. This can be done fairly simply by reusing our existing code for conditional login.

We’ll change the loginWebAuthnAccount to take an additional parameter, conditional, which will be a boolean indicating whether this login attempt is to setup the conditional UI or triggered by submitting the login form.

If it’s false, we won’t request conditional mediation and instead we’ll look up the credentials corresponding to the email the user entered and ask WebAuthn for one of those:

async function loginWebAuthnAccount(loginForm, conditional) {
    // ...
    let allowCredentials = [];
    if (!conditional) {
		const email = loginForm["user[email]"].value;
		const resp = await fetch(`/users/log_in/credentials?email=${email}`);
		const respJSON = await resp.json();
		allowCredentials = respJSON.map((id) => {
			return {
				type: "public-key",
				id: base64ToArrayBuffer(id),
			};
		});
    }

    const getOptions = {
		mediation: conditional ? "conditional" : "optional",
		publicKey: {
			challenge: base64URLToArrayBuffer(challenge),
			rpId: "localhost",
			allowCredentials,
		}
    };
    // ...
}

The “optional” value for the mediation option means that the authenticator isn’t required to display UI, but will do so if its policies dictate that. The allowCredentials array contains objects describing all of the credentials that we want to accept—specifically, their binary IDs as ArrayBuffers.

We look up the user’s credentials so that the one we actually request from the authenticator matches the account that the user is trying to log in with. To handle this, we’ll also wire up an additional route on the backend that returns the base 64-encoded IDs of all the credentials belonging to the user with a given email.

defmodule PhoenixPasskeysWeb.UserSessionController do
  def credentials(conn, %{"email" => email}) do
    ids =
      Accounts.get_credentials_by_email(email)
      |> Enum.map(fn cred -> Base.encode64(cred.id) end)

    json(conn, ids)
  end
end

The get_credentials_by_email function is quite simple. It just looks up a user by email, preloading any credentials they have and then returning them:

defmodule PhoenixPasskeys.Accounts do
  def get_credentials_by_email(email) do
    Repo.one(from u in User, where: u.email == ^email, preload: :credentials)
    |> case do
      %{credentials: credentials} -> credentials
      nil -> []
    end
  end
end

Back in the JS, we can tweak the setup code to pass true for the conditional parameter in the initial request and also register a submit handler on the login form that will invoke it with false:

document.addEventListener("DOMContentLoaded", () => {
    // ...
	const loginForm = document.getElementById("login-form");
	if (loginForm) {
		loginWebAuthnAccount(loginForm, true);
		loginForm.addEventListener("submit", (event) => {
			event.preventDefault();
			loginWebAuthnAccount(loginForm, false);
		});
	}
});

And so we’ve handled the case where the user ignores the conditional UI and still types in their email to log in.

Passkey reset

A not-infrequent argument against passkeys is that if your phone falls into a lake, you lose access to all of your passkey-backed accounts. One could argue that this isn’t true because the definition of passkeys means that they’re backed up and thus protected from this sort of event (and indeed, earlier we only permitted registering with backed-up credentials). I think the argument isn’t particularly interesting, however, because you can still have a “Forgot my passkey” option that works just like it does now with passwords.

This is less secure than a passkey implementation that has no “Forgot” option. But it’s no less secure than current password-based systems, and I think the UX/security tradeoff here falls on the UX side—people will, inevitably, lose access to their passkeys while retaining access to their email.

Implementing isn’t too complicated, fortunately, since we can reuse much of the registration code. First, the JavaScript. The only change necessary is attaching the registration function to the reset form as well.

document.addEventListener("DOMContentLoaded", () => {
    // ...
	const resetForm = document.getElementById("reset-passkey-form");
	if (resetForm) {
		resetForm.addEventListener("submit", (event) => {
			event.preventDefault();
			registerWebAuthnAccount(resetForm);
		});
	}
});

In the HTML for the reset form, we we need to include the email in the same form field as the registration form (and also add an ID to the <form> element):

<input type="hidden" name="user[email]" value={@user.email} />

In the function for the reset route (which is changed to POST from PUT, to match the signup route), we take the credential ID and public key and use them to update the user’s credentials:

defmodule PhoenixPasskeysWeb.UserResetPasswordController do
  def update(conn, %{
        "credential_id" => credential_id,
        "public_key_spki" => public_key_spki
      }) do
    credential_id = Base.decode64!(credential_id)
    public_key_spki = Base.decode64!(public_key_spki)
    case Accounts.reset_user_credentials(conn.assigns.user, credential_id, public_key_spki) do
      :ok ->
        conn
        |> put_flash(:info, "Passkey reset successfully.")
        |> UserAuth.log_in_user_without_redirect(conn.assigns.user)
        |> json(%{status: :ok})

      {:error, _} ->
        conn
        |> put_flash(:error, "Error resetting passkey")
        |> json(%{status: :error})
    end
  end
end

After updating, we log the user in if applicable and return JSON with the appropriate status.

The reset_user_credentials function works very similarly to the original reset password function that was part of the template: it deletes all the user’s existing sessions, and then removes their existing credentials and creates a new one:

defmodule PhoenixPasskeys.Accounts do
  def reset_user_credentials(user, credential_id, public_key_spki) do
    Ecto.Multi.new()
    |> Ecto.Multi.delete_all(
      :old_credentials,
      from(a in UserCredential, where: a.user_id == ^user.id)
    )
    |> Ecto.Multi.insert(
      :new_credential,
      UserCredential.changeset(%UserCredential{}, %{
        id: credential_id,
        public_key_spki: public_key_spki,
        user_id: user.id
      })
    )
    |> Ecto.Multi.delete_all(:tokens, UserToken.user_and_contexts_query(user, :all))
    |> Repo.transaction()
    |> case do
      {:ok, _} -> :ok
      {:error, _, changeset, _} -> {:error, changeset}
    end
  end
end

It’s worth noting that this does have slightly weaker security properties than the phx.gen.auth reset password implementation. With that approach, leaking a password reset token does not necessarily result in an account takeover, since whoever obtained the leaked token may not know the target user’s email address. Since the auth template forces the user to re-login after a reset, this prevents someone without the email address from gaining access even if they change the password.

But since logging in with a passkey is functionally a single factor, resetting it means gaining access to the account. So, a leaked reset token gives the bearer control over the account. This is an argument against having a reset option, but whether this is a concern in practice depends on your specific circumstances.

Conclusion

As noted, this is not a complete implementation. There are a handful of places where I’ve left things unfinished since this isn’t meant to be production-level code. There are also a few places where there are security decisions that need to be made on a more contextual basis, that I’ve tried to note. And, of course, you wouldn’t really want to only permit signing in with passkeys and wholesale drop the passwords column from your database.

Nonetheless, I hope this has been a helpful look at how to implement passkeys in an Elixir/Phoenix application. You can find the complete repo here, and it may also be useful to look at the specific commit where passkey support was added.

Comments

Comments powered by ActivityPub. To respond to this post, enter your username and instance below, or copy its URL into the search interface for client for Mastodon, Pleroma, or other compatible software. Learn more.

Reply from your instance: