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

The service provider binds each contract to a default singleton. Bind your own implementation and it is swapped everywhere:

ContractDefault bindingPurpose
PasskeyServicePasskeyManagerThe facade root — for($user), the usernameless ceremony and attestationFormats().
ChallengeRepositoryCacheChallengeRepositorySingle-use, TTL-bound challenge storage.
AttestationVerifierAttestationGateThe attestation trust policy — ladder, anchors, AAGUID allow-list.
HasPasskeysYour model via InteractsWithPasskeysA model that owns passkeys.

Prefer rebinding a single action (for example RevokePasskeyAction) over replacing the whole service — the manager and the account handle resolve every action from the container, so the override applies to the facade, the injected service and the model verbs alike. A full custom PasskeyService implements four methods — for($user), which returns a UserPasskeys handle, authenticationOptions(?$overrides), authenticate($response, ?$expect) and attestationFormats().

A custom challenge store

CacheChallengeRepository stores each challenge under passkeys:challenge:<ceremonyId> in the cache store named by challenge.store, and pull() fetches and forgets under the store’s lock, so of two requests racing one ceremony exactly one receives the challenge. To store challenges elsewhere, implement the contract and bind it:

use RoundlyConsulting\Passkeys\Contracts\ChallengeRepository;
use RoundlyConsulting\Passkeys\DataTransferObjects\ChallengeData;

final class DatabaseChallengeRepository implements ChallengeRepository
{
    public function put(string $ceremonyId, ChallengeData $challenge, int $ttl): void { /* … */ }
    public function pull(string $ceremonyId): ?ChallengeData { /* fetch + delete atomically */ }
}

// In a service provider:
$this->app->singleton(ChallengeRepository::class, DatabaseChallengeRepository::class);

pull() must return null when the challenge is missing, expired or already consumed, and must stay single-use under concurrency — a get followed by a separate delete is not enough on its own.

Adding an attestation format

Attestation is split in two. Format verifiers prove maths only and return an AttestationResult; AttestationGate owns every trust ruling — the ladder, chain linkage, validity dates, anchors and the AAGUID allow-list. Write a verifier:

use RoundlyConsulting\Crypto\Cose\AuthenticatorData;
use RoundlyConsulting\Passkeys\Attestation\AttestationResult;
use RoundlyConsulting\Passkeys\Attestation\AttestationVerifier;
use RoundlyConsulting\Passkeys\DataTransferObjects\AttestationObject;
use RoundlyConsulting\Passkeys\Enums\AttestationType;

final class AndroidSafetynetVerifier implements AttestationVerifier
{
    public function verify(
        AttestationObject $attestation,
        AuthenticatorData $authenticatorData,
        string $clientDataHash,
    ): AttestationResult {
        // Prove the maths; throw InvalidAttestation on any failure. Never rule on
        // trust — the gate owns that.
        return AttestationResult::chained('android-safetynet', AttestationType::Basic, $chain);
    }
}

Then rebind the registry with it added — the gate keeps every trust ruling:

use Illuminate\Contracts\Foundation\Application;
use RoundlyConsulting\Passkeys\Attestation\AppleAttestationVerifier;
use RoundlyConsulting\Passkeys\Attestation\AttestationVerifierRegistry;
use RoundlyConsulting\Passkeys\Attestation\NoneAttestationVerifier;
use RoundlyConsulting\Passkeys\Attestation\PackedAttestationVerifier;
use RoundlyConsulting\Passkeys\Support\CredentialCrypto;

// In a service provider:
$this->app->singleton(AttestationVerifierRegistry::class, fn (Application $app) => new AttestationVerifierRegistry([
    'none' => new NoneAttestationVerifier,
    'packed' => new PackedAttestationVerifier($app->make(CredentialCrypto::class)),
    'apple' => new AppleAttestationVerifier,
    'android-safetynet' => new AndroidSafetynetVerifier,
]));

Build certificate chains from the raw DER bytes CBOR carries, never from the base64 x5c helper — that is the JOSE shape and would reject every genuine authenticator. To replace attestation policy wholesale, bind your own AttestationVerifier; the ceremony calls it and nothing else.

Custom user wiring

Implement HasPasskeys directly instead of using the trait when you need bespoke handle storage or name resolution. The four contract methods are all the ceremonies need — just keep passkeyUserHandle() stable.

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.