Skip to content

Passkeys (WebAuthn)

Passkeys provide a highly secure, phishing-resistant alternative to passwords by utilizing public-key cryptography (WebAuthn). Authkestra supports Passkeys out of the box through the WebAuthnAuthMethod which is directly integrated into the Engine for seamless multi-factor and primary authentication flows.

To use Passkeys, you must first enable the webauthn feature in your Cargo.toml:

[dependencies]
authkestra-engine = { version = "0.9", features = ["webauthn"] }

Authkestra uses webauthn-rs to power passkey support. You’ll need to instantiate a WebauthnBuilder, configure your Relaying Party (RP) ID and Origin, and pass it into a WebAuthnAuthMethod instance when building your Engine.

use std::sync::Arc;
use webauthn_rs::prelude::WebauthnBuilder;
use url::Url;
use authkestra::Authkestra;
let rp_id = "example.com";
let origin = Url::parse("https://example.com").unwrap();
let webauthn = WebauthnBuilder::new(rp_id, &origin)
.unwrap()
.rp_name("Authkestra Demo")
.build()
.unwrap();
// `my_store` implements the `CredentialStore` trait.
// `with_webauthn` is shorthand for `.with_auth_method(WebAuthnAuthMethod::new(..))`
// and registers the method under the name `"webauthn"`.
let engine = Authkestra::builder()
.with_webauthn(Arc::new(webauthn), my_store)
.build();

A runnable example combining passkeys with TOTP lives in the repository:

Terminal window
cargo run -p authkestra --example axum_mfa_server --all-features

Registration is also a two-step ceremony, driven directly on the WebAuthnAuthMethod:

use authkestra_engine::auth::webauthn::WebAuthnAuthMethod;
let method = WebAuthnAuthMethod::new(Arc::new(webauthn), my_store);
// 1. Start: return `challenge` to the browser for `navigator.credentials.create()`,
// and stash `reg_state` in the user's session.
let (challenge, reg_state) = method.start_register("user_123", "ada")?;
// 2. Finish: verify the attestation and persist the passkey via the CredentialStore.
let passkey = method.finish_register("user_123", reg_response, reg_state).await?;

user.id in the creation options is the user handle. The authenticator stores it inside the credential and returns it as response.userHandle on every later assertion, which is what makes discoverable (“usernameless”) sign-in able to resolve an account. It must be stable for the lifetime of the account and map back to exactly one user.

start_register derives the handle from user_id with derive_user_handle:

  • a user_id that already parses as a UUID is used verbatim;
  • anything else (CUID, ULID, prefixed id, integer) is hashed into a UUIDv5 over the documented USER_HANDLE_NAMESPACE constant.

Because the derivation is a plain UUIDv5, you can reproduce the same handle in any language to index your own users by handle.

If your application allocates handles itself — or needs a display name distinct from the username, since start_register passes username for both — use start_register_with_handle:

use authkestra_engine::auth::webauthn::derive_user_handle;
let handle = derive_user_handle("user_123"); // or your own stable handle
let (challenge, reg_state) =
method.start_register_with_handle(handle, "ada", "Ada Lovelace")?;

WebAuthn uses a two-step “ceremony”:

  1. Start: The server generates a cryptographic challenge and temporarily stores the session state.
  2. Finish: The client (browser) signs the challenge with the authenticator and returns the signature to the server. The server verifies the signature and completes the authentication via the unified Engine::authenticate method.

When the user wants to sign in, call start_authentication. You must pass the user’s previously registered Passkey objects (retrieved from your credential store). Engine::start_webauthn is the usual entry point; to call it directly on a WebAuthnAuthMethod you must bring the authkestra_engine::auth::WebAuthnStarter trait into scope, since that is where the method lives:

// `passkeys` is a `Vec<webauthn_rs::prelude::Passkey>` loaded from your store
let (challenge_response, auth_state) = engine.start_webauthn(&passkeys)?;

You return the challenge_response (which contains the PublicKeyCredentialRequestOptions) to the client so it can invoke navigator.credentials.get(). You must also temporarily store the auth_state (the internal session state) associated with this login attempt (e.g. in a session or Redis).

Once the client returns the signed assertion, pass it to the engine’s authenticate method using the AuthInput::WebAuthnAuthentication variant. The engine expects the auth_state_json (the serialized internal session state created during start_authentication) to be provided.

use authkestra_engine::auth::{AuthInput, AuthResult};
let result = engine.authenticate(AuthInput::WebAuthnAuthentication {
// The internal user ID you are trying to authenticate
user_id: "user_123".to_string(),
// `id` from the PublicKeyCredential response
credential_id: "base64_url_credential_id".to_string(),
// `response.clientDataJSON` from the PublicKeyCredential response, base64url-encoded
client_data_json: "base64_url_client_data".to_string(),
// `response.authenticatorData` from the PublicKeyCredential response, base64url-encoded
authenticator_data: "base64_url_auth_data".to_string(),
// `response.signature` from the PublicKeyCredential response, base64url-encoded
signature: "base64_url_signature".to_string(),
// `response.userHandle` from the PublicKeyCredential response, base64url-encoded (optional)
user_handle: None, // Or Some("user_handle") if returned
// The state string stored during `start_webauthn`, retrieved from your session/cache store
auth_state_json: Some(auth_state_json_string),
}).await?;
match result {
AuthResult::Success(identity) => {
println!("Successfully authenticated as: {}", identity.external_id);
}
AuthResult::MfaRequired { mfa_token, allowed_methods, .. } => {
// Handle step-up authentication if they need a second factor!
}
}

Authkestra will perform rigorous cryptographic validation of the assertion against the stored public key, update the signature counter in the credential store to detect cloned authenticators, and return the authenticated Identity if successful!