| description | Let users sign in with a fingerprint, face, screen lock, or security key instead of a password. |
|---|
A passkey is a WebAuthn credential stored by the user's device or password manager. The private key never leaves the authenticator and the browser will only offer it back to the site that created it, so a passkey cannot be phished, guessed, reused, or leaked in a database breach.
Auth stores passkeys, runs both WebAuthn ceremonies, and exposes them in three places: the login page, a self-service page at auth/passkeys, and a tab in Admin when editing a user.
{% hint style="info" %} A passkey login where the user verified themselves (biometric, PIN, or screen lock) also satisfies a Multi-Factor Auth challenge, so those users are not asked for a second factor. The user has already presented two factors: the device, and whatever unlocked it. {% endhint %}
ext-opensslandext-mbstring.ext-sodiumis optional, and only needed for authenticators which use Ed25519 keys.- A secure context: HTTPS, or
localhostfor development. Browsers refuse WebAuthn anywhere else. - A single host per app. See Relying Party ID.
Run migrations so the user_passkey table exists, then turn passkeys on with an environment variable. The flag is per environment, so production can use passkeys while staging and local leave them off:
AUTH_PASSKEYS_ENABLED=true
Admin under Settings → Authentication → Login shows whether they are on, and the current Relying Party ID, but cannot change either.
Nothing changes for existing users until they register a passkey; password login is untouched. Requires ext-openssl.
{% stepper %} {% step %}
If they have no passkey yet, and this browser has not been asked before, they are offered one on a short interstitial page. Declining sets a cookie and they are not asked again on that browser. {% endstep %}
{% step %}
From the nudge, or at any time from auth/passkeys. They can name each one so they can tell their laptop from their phone.
{% endstep %}
{% step %}
The login page shows a Sign in with a passkey button, and the identifier field offers saved passkeys in its own autofill dropdown. Either route signs them straight in; no username or password is typed. {% endstep %} {% endstepper %}
Users manage their own passkeys at auth/passkeys: rename, remove, and see when each was last used. Passkeys which sync through the user's password manager are marked, so they know which ones exist on more than one device.
The Relying Party (RP) ID is the domain a passkey is bound to. It defaults to the host of BASE_URL and is shown, read-only, on the settings page.
{% hint style="danger" %} Changing the RP ID invalidates every passkey already registered. Authenticators bind credentials to the RP ID, so after a change the browser will not offer the old ones back and users must register again. {% endhint %}
Override it only when you have a good reason, such as sharing passkeys across app.example.com and www.example.com by setting the RP ID to the registrable parent domain:
AUTH_PASSKEY_RP_ID=example.com
Ceremonies are also checked against a list of permitted origins, derived from BASE_URL and SECURE_BASE_URL. Add more if the app is served from another origin (JSON is decoded automatically):
AUTH_PASSKEY_ALLOWED_ORIGINS=["https://app.example.com"]
An app which has replaced auth/views/login/form.php owns its own markup and assets, so the module does not add the button or the JavaScript to it. Opt back in with the passkey helper:
<?php
loadPasskeyAssets();
echo passkeyLoginButton($return_to);passkeysEnabled() and passkeyRegisterButton() are available too. Each returns an empty string when passkeys are disabled, so they are safe to call unconditionally.
For the button to work, the surrounding <form> needs id="login-form" and data-passkey-site-url (the value of siteUrl()); the identifier input needs autocomplete="username webauthn" and data-passkey-conditional for autofill.
Editing a user shows a Passkeys tab listing their credentials, when each was added and last used, and a checkbox to revoke. Revoking is immediate and cannot be undone; the user must register the device again.
Registrations and revocations are recorded as did_add_passkey and did_remove_passkey user events, and a passkey login is recorded as did_log_in with provider set to passkey. They also fire live events (Events::USER_DID_ADD_PASSKEY and Events::USER_DID_REMOVE_PASSKEY) so other components can react without this module knowing they exist. Both pass the user ID and the Resource\User\Passkey; on revoke the passkey is the in-memory snapshot from before the delete.
The browser talks to these endpoints; they are not intended as a public API. All of them require a JSON request body and a same-origin request, and all return 404 when passkeys are disabled.
| Endpoint | Auth | Purpose |
|---|---|---|
GET api/auth/passkey/index |
Logged in | List the user's passkeys |
POST api/auth/passkey/register |
Logged in | Begin registration |
POST api/auth/passkey/attest |
Logged in | Finish registration |
POST api/auth/passkey/challenge |
Public | Begin sign-in |
POST api/auth/passkey/assert |
Public | Finish sign-in |
POST api/auth/passkey/rename |
Logged in | Rename one |
POST api/auth/passkey/revoke |
Logged in | Remove one |
The Passkey service is the entry point. The pure ceremony methods touch neither the database nor the session, so they can be unit tested directly.
use Nails\Auth\Constants;
use Nails\Auth\Service\Passkey;
use Nails\Factory;
/** @var Passkey $oPasskey */
$oPasskey = Factory::service('Passkey', Constants::MODULE_SLUG);
if ($oPasskey->isEnabled()) {
$oOptions = $oPasskey->createRegistrationOptions($oUser);
}Every failure extends Nails\Auth\Exception\Passkey\PasskeyException, so a single catch covers a whole ceremony. Catch the subclasses when you need to tell a stale challenge from a bad signature.
To sign a user in from an assertion you have already collected, use Authentication::loginWithPasskey(), which applies the same lockout and suspension rules as password login.
- Sign counters. Authenticators which keep a counter have it checked on every login; one that fails to advance signals a cloned credential and the login is refused. Many platform authenticators keep no counter and always report zero; those are accepted, as the specification intends.
- Public keys are stored in plaintext. A public key is public by definition, and encrypting it would tie every passkey to
PRIVATE_KEYrotation. - User handles are derived, not stored on the user. The handle the authenticator sees is an HMAC of the user's ID, so no column was added to the
usertable. It is written to each passkey row at registration, so rotatingPRIVATE_KEYdoes not break credentials which already exist. - No captcha on passkey login. A passkey is phishing-resistant and the same lockout rules apply, so a captcha adds friction without adding protection.
- A passkey login skips the temporary and expired password checks, because no password took part in it.