Agora

WebAuthn / Passkeys

Passkeys as a second factor — registration and the login challenge.

AuthKit supports WebAuthn passkeys as a second factor, alternative to TOTP: biometrics, a device PIN, or a security key, with nothing to type. Like TOTP, it's opt-in and driven by optional AccountStore methods — a store without passkey support simply hides the section.

Configuration

The Relying Party (RP) parameters are derived from issuer when omitted: rpId = issuer hostname (no port), origin = issuer origin, rpName = the branding / mfaIssuer name. Override them with webauthn:

config/authkit.ts
defineConfig({
  issuer: env.get('AUTHKIT_ISSUER'),
  // ...
  webauthn: {
    rpName: 'Acme',
    rpId: 'auth.example.com',
    origin: 'https://auth.example.com',
  },
})
FieldTypeDefaultNotes
rpNamestring?branding / mfaIssuerRP name shown by the authenticator.
rpIdstring?issuer hostnameRP ID (hostname, no port).
originstring | string[]?issuer originExpected origin(s) in verification.

The model mixin

Add the withWebauthnCredential mixin so the store has somewhere to persist credentials (id, public key, counter, transports):

app/models/auth_user.ts
import { compose } from '@adonisjs/core/helpers'
import { BaseModel } from '@adonisjs/lucid/orm'
import {
  withAuthUser,
  withCredentials,
  withMfa,
  withWebauthnCredential,
} from '@adonis-agora/authkit-server'

export default class AuthUser extends compose(
  BaseModel,
  withAuthUser(),
  withCredentials(),
  withMfa(),
  withWebauthnCredential()
) {}

Registration (account console)

The account console (mounted by registerAuthHost) manages passkeys on the MFA page:

RouteAction
POST /account/mfa/passkeys/optionsBegin registration — returns generateRegistrationOptions + a challenge
POST /account/mfa/passkeys/verifyFinish — verifyRegistrationResponse against the stored challenge; on success persists the credential and enables MFA
POST /account/mfa/passkeys/:id/removeRemove a passkey

The store exposes the begin/finish pair as generatePasskeyRegistrationOptions / verifyPasskeyRegistration, plus listPasskeys (a PasskeySummary — never the public key or counter) and removePasskey.

The challenge returned by a begin call must be stored by the controller (in the session) and passed back as expectedChallenge to the matching finish call — the store keeps no challenge state between calls.

The login challenge

When an account has a passkey enrolled, the login interaction offers it as an alternative to the TOTP code:

RouteAction
POST /auth/interaction/:uid/passkey/optionsBegin authentication — generatePasskeyAuthenticationOptions
POST /auth/interaction/:uid/passkey/verifyFinish — verifyPasskeyAuthentication; updates the stored signature counter

Registering and removing a passkey emit the passkey.registered / passkey.removed audit events.

Passkey autofill (conditional mediation)

When auth_methods.passkeyAutofill is true (the default when passkeys are configured), the built-in login screen activates WebAuthn conditional mediation: the browser shows passkey suggestions directly inside the email/username input field. The user never has to click a separate "Sign in with passkey" button.

The input must have autocomplete="username webauthn". The server endpoint receives an optionsMode=discoverable flag and returns options with an empty allowCredentials list so any enrolled passkey can respond.

Runtime toggle:

# Disable passkey autofill (passkey button still works)
node ace authkit:settings:set auth_methods '{"passkeyAutofill":false}'

Custom login screens (React hosts): use usePasskeyAutofill from @adonis-agora/authkit-react to replicate the same behaviour on a fully custom login page. See React — usePasskeyAutofill.

Browser support: Chrome 108+, Safari 16+, Edge 108+. Browsers without support silently skip the ceremony — the standard login form is always the fallback.

Fail-safe: the ceremony is started with an AbortController. Any error, abort, or lack of support is swallowed; the standard login continues unaffected.

On this page