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

Registration enrols a new passkey for a signed-in user in two calls on the account handle: build creation options for the browser, then verify its response and persist the credential. The optional name is a friendly label for a “your passkeys” screen:

use RoundlyConsulting\Passkeys\Facades\Passkeys;
use RoundlyConsulting\Passkeys\DataTransferObjects\RegistrationResponseData;

// 1. Server -> client: creation options (also stores a single-use challenge).
$options = Passkeys::for($user)->registrationOptions();
return response()->json($options); // feed publicKey to navigator.credentials.create()

// 2. Client -> server: verify the attestation response and persist the credential.
//    Pass an optional friendly name for a "your passkeys" screen.
$passkey = Passkeys::for($user)->register(
    RegistrationResponseData::fromArray($request->validated()),
    name: 'MacBook Touch ID',
);

Creation options

registrationOptions() returns a CreationOptionsData. It is JsonSerializable into exactly the shape navigator.credentials.create({ publicKey }) expects — binary members base64url-encoded, with a ceremonyId alongside publicKey:

{
  "ceremonyId": "…40 chars…",
  "publicKey": {
    "rp":   { "id": "example.com", "name": "Example" },
    "user": { "id": "…b64url…", "name": "[email protected]", "displayName": "Jane Doe" },
    "challenge": "…b64url…",
    "pubKeyCredParams": [ { "type": "public-key", "alg": -7 }, { "type": "public-key", "alg": -257 } ],
    "timeout": 60000,
    "attestation": "none",
    "excludeCredentials": [ { "type": "public-key", "id": "…b64url…", "transports": ["internal"] } ],
    "authenticatorSelection": {
      "residentKey": "required",
      "requireResidentKey": true,
      "userVerification": "required"
    }
  }
}
  • excludeCredentials lists the user’s existing passkeys, so the same authenticator can’t enrol twice.
  • The single-use challenge is stored server-side under the ceremonyId, for challenge.ttl seconds.
  • A stateless host echoes ceremonyId back with the response; a session-based host can echo it or store it in the session.

The response payload

RegistrationResponseData::fromArray() reads the standard browser JSON and strictly base64url-decodes the binary members: rawId (or id), ceremonyId, response.clientDataJSON, response.attestationObject and the optional response.transports list. A malformed payload throws InvalidClientData.

What register() verifies

register() runs the WebAuthn §7.1 checks in order and returns the stored Passkey, or throws a PasskeyException subclass:

  • clientDataJSON decodes and its type is webauthn.create.
  • The challenge for the ceremonyId exists, was minted for registration, and matches in constant time.
  • The challenge was minted for this user — defence-in-depth for admin-on-behalf flows.
  • The origin is allow-listed and the cross-origin policy holds.
  • The attestation object CBOR-decodes strictly, its fmt is a well-formed format identifier, and the RP ID hash matches.
  • User presence is set, user verification is set when required, and the backup flags are consistent. Backup eligibility is stored as backup_eligible and stays fixed for the credential’s life.
  • Attested credential data is present with a supported, offered algorithm.
  • The attestation statement is verified — or, by default, only recorded (see Attestation).
  • The credential ID is not already registered, revoked ones included. Then the credential is persisted and PasskeyRegistered fires.

Per-call overrides

Tune a single ceremony without changing the global defaults — null members fall back to config. Beyond user verification, attestation and timeout you can opt into a cross-platform (roaming security-key) or a non-resident credential:

use RoundlyConsulting\Passkeys\DataTransferObjects\RegistrationOptionsOverrides;
use RoundlyConsulting\Passkeys\Enums\AttestationConveyance;
use RoundlyConsulting\Passkeys\Enums\AuthenticatorAttachment;
use RoundlyConsulting\Passkeys\Enums\ResidentKey;
use RoundlyConsulting\Passkeys\Enums\UserVerification;

$options = Passkeys::for($user)->registrationOptions(new RegistrationOptionsOverrides(
    userVerification: UserVerification::Preferred,
    attestation: AttestationConveyance::Direct,
    timeoutMs: 30_000,
    residentKey: ResidentKey::Discouraged,
    authenticatorAttachment: AuthenticatorAttachment::CrossPlatform,
));

The default posture stays residentKey: required (usernameless), and authenticatorSelection serialises byte-identically when no override is given.

Using the actions directly

Both calls are single actions you can resolve and call yourself (see DI and actions):

use RoundlyConsulting\Passkeys\Actions\GenerateRegistrationOptionsAction;
use RoundlyConsulting\Passkeys\Actions\VerifyRegistrationAction;

$options = app(GenerateRegistrationOptionsAction::class)->execute($user, $overrides);
$passkey = app(VerifyRegistrationAction::class)->execute($user, $response, $name);

The package ships no route, controller, CSRF handling, validation, throttling or front-end JavaScript. Wire one endpoint that returns the options JSON and a second that receives the browser’s response and calls Passkeys::for($user)->register().

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.