NewWe open-sourced 50+ Laravel packages
Custom AI apps, agents and automation — Roundly ConsultingRoundly
All packages

Any model that can own passkeys implements the HasPasskeys contract; the InteractsWithPasskeys trait is a ready-made implementation. First add a nullable, unique column for the opaque, non-PII user handle — the passkeyUserHandle() Blueprint macro creates it on any account table, named after passkeys.user.handle_column:

// database/migrations/xxxx_add_passkey_user_handle_to_users_table.php
Schema::table('users', function (Blueprint $table): void {
    $table->passkeyUserHandle();   // string(passkeys.user.handle_column)->nullable()->unique()
});

Then wire the model:

use Illuminate\Foundation\Auth\User as Authenticatable;
use RoundlyConsulting\Passkeys\Concerns\InteractsWithPasskeys;
use RoundlyConsulting\Passkeys\Contracts\HasPasskeys;

final class User extends Authenticatable implements HasPasskeys
{
    use InteractsWithPasskeys;
}

A host with several account models (guards) adds the column to every table whose model owns passkeys.

The contract

These four methods are all the ceremonies need. The trait implements them; implement them yourself when you need bespoke handle storage or name resolution:

interface HasPasskeys
{
    public function passkeyUserHandle(): string;   // opaque, non-PII, stable per user
    public function passkeyUserName(): string;      // authenticator "name" (e.g. email)
    public function passkeyDisplayName(): string;   // authenticator "display name" (e.g. full name)
    public function passkeys(): MorphMany;          // the user's stored credentials
}

Ceremony verbs on the model

The trait also exposes the ceremonies as verbs, so the user model is the subject. They are shorthand for the account handle — each delegates to Passkeys::for($this), so host overrides and Passkeys::fake() see them too:

$options = $user->passkeyRegistrationOptions();                   // Passkeys::for($user)->registrationOptions()
$passkey = $user->registerPasskey($response, 'MacBook Touch ID'); // ->register()
$options = $user->passkeyAuthenticationOptions($overrides);       // ->authenticationOptions()

$user->hasPasskeys();   // ->exists() — at least one active (non-revoked) passkey
$user->passkeyCount();  // ->count()
  • passkeys() — the MorphMany relationship to the user’s stored credentials.
  • passkeyRegistrationOptions(?RegistrationOptionsOverrides) — creation options for this user.
  • registerPasskey(RegistrationResponseData, ?string $name) — verify and store a credential for this user.
  • passkeyAuthenticationOptions(?AuthenticationOptionsOverrides) — request options bound to this user’s credentials.
  • hasPasskeys() / passkeyCount() — whether and how many active (non-revoked) passkeys the account holds.

The user handle

The handle is generated lazily and persisted on the first registrationOptions() or user-bound authenticationOptions() call — a small write on an otherwise read-shaped call. The write is surgical:

  • It updates only the handle column, so an unsaved edit on the model stays unsaved — the model is never re-saved.
  • It only fills an empty column, so under concurrent first-time requests the first handle stored wins and every request uses it.
  • It never inserts an unsaved model: on a model that doesn’t exist yet the handle is only set, and the model’s own save persists it.

That makes eager generation a one-liner — call passkeyUserHandle() while the user is being created:

// e.g. in a User creating() observer
$user->passkeyUserHandle(); // sets base64url(random_bytes(passkeys.user.handle_bytes))

If you mint handles yourself, they must be base64url of random bytes — at least 16, no padding — for example Base64Url::encode(random_bytes(32)) with RoundlyConsulting\Crypto\Codec\Base64Url. The ceremony decodes the handle strictly, so a string that merely looks like one, such as Str::random(43), is refused with InvalidClientData.

Keep the handle stable. A user-bound sign-in compares the handle stored on each credential at registration with the value passkeyUserHandle() returns, so rotating it locks the user out of user-scoped logins. Usernameless logins are unaffected.

Name and display name

The authenticator shows an account name and a display name. They default to the model’s email and name attributes (falling back to the primary key). Repoint them globally with passkeys.user.name_attribute and display_name_attribute, or override the methods for per-model logic:

public function passkeyDisplayName(): string
{
    return trim($this->first_name.' '.$this->last_name) ?: $this->email;
}

Show your open-source love

This package is free and MIT-licensed. If it saves you time, a one-off donation or a Patreon membership keeps it maintained, tested and documented.

More ways to support, including crypto

By donating, you agree to our donation terms.

Want this built into your product?

We integrate our packages into custom Laravel and AI builds. Tell us what you're working on and we'll reply within 48 hours.