Extending
The service provider binds each contract to a default singleton. Bind your own implementation and it is swapped everywhere:
| Contract | Default binding | Purpose |
|---|---|---|
PasskeyService | PasskeyManager | The facade root — for($user), the usernameless ceremony and attestationFormats(). |
ChallengeRepository | CacheChallengeRepository | Single-use, TTL-bound challenge storage. |
AttestationVerifier | AttestationGate | The attestation trust policy — ladder, anchors, AAGUID allow-list. |
HasPasskeys | Your model via InteractsWithPasskeys | A 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 cryptoBy 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.