KMS includes a WebAuthn/passkeys reference implementation backed by the wax_ FIDO2 library.
Use this for:
- passwordless principal authentication;
- MFA/factor authentication after password login.
Architecture
Keep browser traffic on your application server.
- App server calls
KMS.WebAuthnorKMS.Client.Local/KMS.Client.Remote. - KMS returns JSON-compatible
publicKeyoptions. - App server sends those options to browser JavaScript.
- Browser calls
navigator.credentials.create()ornavigator.credentials.get(). - App server forwards the browser response to KMS.
- KMS verifies the WebAuthn ceremony server-side.
Do not expose KMS directly to browsers. The remote HTTP API is still admin-token backend API only.
Configuration
config :kms, :webauthn,
rp_id: "example.com",
rp_name: "Example App",
origins: ["https://app.example.com"],
attestation: "none",
user_verification: "preferred",
challenge_ttl_ms: :timer.minutes(5)Per-call :rp_id and :origin are accepted only when present in configured allowlists. Add more RP IDs with :rp_ids and more browser origins with :origins.
Attestation defaults to "none".
Enrollment / registration
{:ok, enrollment} =
KMS.begin_webauthn_registration("users", "alice",
rp_id: "example.com",
rp_name: "Example App",
origin: "https://app.example.com"
)KMS returns:
%{
challenge_id: "...",
public_key: %{...}
}Browser side must convert base64url fields to ArrayBuffer before calling WebAuthn:
const publicKey = decodeCreationOptions(enrollment.public_key)
const credential = await navigator.credentials.create({ publicKey })
const response = encodeCredential(credential)Finish enrollment:
{:ok, credential} =
KMS.finish_webauthn_registration(enrollment.challenge_id, response,
label: "MacBook passkey",
transports: ["internal"]
)KMS stores credential ID, COSE public key, RP ID, sign count, AAGUID, transports, backup flags, and label. Private keys never leave authenticators and are never stored by KMS.
Passwordless authentication
{:ok, options} = KMS.begin_webauthn_authentication("users", "alice")Browser:
const publicKey = decodeRequestOptions(options.public_key)
const assertion = await navigator.credentials.get({ publicKey })
const response = encodeCredential(assertion)Finish:
{:ok, session} = KMS.finish_webauthn_authentication(options.challenge_id, response)Passwordless sessions authenticate the principal but do not unwrap the password-protected principal private key. KMS.Sessions.decrypt_private_key/1 returns {:error, :private_key_unavailable} for those sessions. KMS authorization checks based on principal/session still work.
MFA factor flow
Create a factor after enrolling at least one WebAuthn credential:
{:ok, factor} =
KMS.create_principal_factor("users", "alice", %{
provider: "webauthn",
label: "passkey",
position: 1,
config: %{"credential_ids" => [credential.credential_id]}
})credential_ids is optional. Omit it to allow any enabled WebAuthn credential for the principal/RP ID.
Password login returns pending session:
{:ok, pending} = KMS.create_session("users", "alice", %{password: password})
pending.challenge == %{
provider: "webauthn",
type: "webauthn",
public_key: %{...}
}Advance session:
{:ok, session} =
KMS.advance_session(pending.secret, %{
challenge_id: pending.challenge.challenge_id,
webauthn_assertion: response
})Factor mode preserves the password-unwrapped principal private key inside the session flow.
Remote HTTP API
All routes require the existing admin bearer token:
POST /api/webauthn/registration/begin
POST /api/webauthn/registration/finish
POST /api/webauthn/authentication/begin
POST /api/webauthn/authentication/finishExample begin request:
{
"kind": "users",
"ref": "alice",
"opts": {
"rp_id": "example.com",
"origin": "https://app.example.com"
}
}Example finish request:
{
"challenge_id": "...",
"response": { "id": "...", "rawId": "...", "type": "public-key", "response": { } }
}JavaScript encoding helpers
KMS represents binary WebAuthn fields as base64url strings in JSON. Convert before and after browser APIs.
function base64urlToBuffer(value) {
const base64 = value.replace(/-/g, "+").replace(/_/g, "/")
const padded = base64.padEnd(Math.ceil(base64.length / 4) * 4, "=")
const binary = atob(padded)
return Uint8Array.from(binary, c => c.charCodeAt(0)).buffer
}
function bufferToBase64url(buffer) {
const bytes = new Uint8Array(buffer)
let binary = ""
for (const byte of bytes) binary += String.fromCharCode(byte)
return btoa(binary).replace(/\+/g, "-").replace(/\//g, "_").replace(/=+$/g, "")
}
function decodeCreationOptions(publicKey) {
return {
...publicKey,
challenge: base64urlToBuffer(publicKey.challenge),
user: {...publicKey.user, id: base64urlToBuffer(publicKey.user.id)},
excludeCredentials: (publicKey.excludeCredentials || []).map(c => ({
...c,
id: base64urlToBuffer(c.id)
}))
}
}
function decodeRequestOptions(publicKey) {
return {
...publicKey,
challenge: base64urlToBuffer(publicKey.challenge),
allowCredentials: (publicKey.allowCredentials || []).map(c => ({
...c,
id: base64urlToBuffer(c.id)
}))
}
}
function encodeCredential(credential) {
const response = {}
for (const key of Object.keys(credential.response)) {
const value = credential.response[key]
response[key] = value instanceof ArrayBuffer ? bufferToBase64url(value) : value
}
return {
id: credential.id,
rawId: bufferToBase64url(credential.rawId),
type: credential.type,
response
}
}Security notes
- KMS validates challenge, origin, RP ID, user presence, user verification policy, signature, and sign count.
- Challenges are random, short-lived, stored server-side, and deleted after successful use.
- Registration/assertion JSON is untrusted input.
- Credential public keys are stored; private keys stay in authenticators.
- Credential/challenge internals are redacted from inspect output and HTTP responses.
- Use HTTPS origins in production.
- Configure tight origin/RP ID allowlists per deployment/tenant.