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); // the browser parses publicKey for 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 the WebAuthn JSON form — publicKey is a PublicKeyCredentialCreationOptionsJSON with binary members base64url-encoded, and a ceremonyId travels alongside it. navigator.credentials.create() does not take it directly: the browser converts publicKey with PublicKeyCredential.parseCreationOptionsFromJSON() first (see The browser side):

{
  "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 browser side

The package ships no JavaScript. Fetch the options, pass options.publicKey through PublicKeyCredential.parseCreationOptionsFromJSON() into navigator.credentials.create(), and post credential.toJSON() back with the ceremonyId — the routes are yours, and a Laravel web route also needs its CSRF header:

const options = await (await fetch('/passkeys/options')).json();
const credential = await navigator.credentials.create({
    publicKey: PublicKeyCredential.parseCreationOptionsFromJSON(options.publicKey),
});
await fetch('/passkeys', { method: 'POST', headers: { 'Content-Type': 'application/json' },
    body: JSON.stringify({ ...credential.toJSON(), ceremonyId: options.ceremonyId }) });

Older browsers lack parseCreationOptionsFromJSON() and toJSON(): feature-detect them and, where they are missing, convert challenge, user.id and each excludeCredentials[].id from base64url to an ArrayBuffer before the call, then base64url-encode rawId, response.clientDataJSON and response.attestationObject before posting.

The response payload

RegistrationResponseData::fromArray() reads the standard browser JSON — the shape credential.toJSON() produces — 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. credential.toJSON() does not carry the ceremonyId, so the front end adds it; without it the challenge can’t be found and the ceremony fails with ChallengeExpired.

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, the credential ID is at most 1023 bytes (WebAuthn L3 §7.1 — a longer one is refused with InvalidAuthenticatorData), and its algorithm is supported and offered.
  • The attestation statement is verified — or, by default, only recorded (see Attestation).
  • The credential ID is not already registered, revoked ones included. When two registrations race with the same ID, the unique index decides and the losing request gets the same CredentialAlreadyRegistered. 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.