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

Passkeys::fake() swaps the relying party for PasskeysFake — a programmable, no-crypto double bound under the PasskeyService contract — and returns it for assertions. You can assert your enrolment and login controllers without reproducing authenticator crypto. It performs no CBOR/COSE decoding, signature verification or challenge check, and is bound only for the test. The facade, an injected PasskeyService and the model verbs all see it:

  • Ceremonies are recorded, not performed: register() persists a factory-built credential and authenticate() returns the programmed outcome. The options are built like the real ones except for a canned ceremony ID, challenge and relying party (localhost / Fake): they honour every override, take their defaults from passkeys.* (timeout, user verification, resident key, attestation, algorithms) and, for Passkeys::for($user), list the account’s active passkeys in excludeCredentials / allowCredentials. They never require rp.id or origins, and the creation options decode the user handle exactly as the real service does, so a malformed handle is refused there too.
  • The side effects stay real: a fake register() stores the credential with last_used_at null and fires PasskeyRegistered; a successful authenticate() stamps last_used_at (the counter is not moved) and fires PasskeyAuthenticated. Both go through the app’s event dispatcher, so your listeners run unless the test wraps them in Event::fake(). A programmed failure writes and fires nothing.
  • Calls through the model verbs (registerPasskey() and the rest) are recorded as well — they route through Passkeys::for($this).
  • rename() and revoke() still run the real, ownership-checked actions — writes and PasskeyRenamed / PasskeyRevoked included — and are recorded once they succeed.
  • Reads (all(), find(), count(), exists()) go to the database; attestationFormats() returns the real registry’s formats.
use RoundlyConsulting\Passkeys\Facades\Passkeys;

$fake = Passkeys::fake();

// The fake skips the crypto, not your controller's parsing: RegistrationResponseData::fromArray()
// still needs the browser's shape with base64url members — any bytes will do.
$this->postJson('/passkeys', [
    'id' => 'AAAA',
    'rawId' => 'AAAA',
    'type' => 'public-key',
    'response' => ['clientDataJSON' => 'e30', 'attestationObject' => 'oA'],
])->assertCreated();

$fake->assertRegisteredFor($user);

$this->deleteJson("/passkeys/{$passkey->id}")->assertNoContent();
$fake->assertRevoked($passkey);
$fake->assertNothingRenamed();

// programmable outcomes:
Passkeys::fake()->rejectAuthentication();          // authenticate() throws CredentialNotFound
Passkeys::fake()->authenticatesAs($passkey);       // authenticate() returns it — if still stored, unrevoked and owned
Passkeys::fake()->failRegistrationWith($exception);

Programmable outcomes

MethodEffect
acceptRegistration()register() persists a factory-built ES256 credential for the user (the default).
failRegistrationWith($e)register() throws the given PasskeyException instead of persisting.
acceptAuthentication()authenticate() succeeds (the default).
rejectAuthentication()authenticate() throws CredentialNotFound.
authenticatesAs($passkey)authenticate() returns exactly this credential — refused like the real service once it is revoked, if it was never saved, or when its owner no longer holds it. Seed it with Passkey::factory()->forAuthenticatable($user).

What the fake refuses

The fake refuses what the real verifier refuses without crypto, in the same order: the credential must still be stored and not revoked, its owner must still hold it, and it must meet the AuthenticationExpectation. A refusal is recorded as a failed authentication and throws CredentialNotFound. The owner check is the real verifier’s own — the owner must exist, must not be soft-deleted and must still hold the passkey’s user handle — and applies to the authenticatesAs() passkey and the last fake-registered one. A passkey whose owner type names no model is refused the same way.

One passkey is exempt: the one the fake invents when nothing is seeded — no authenticatesAs(), no fake registration. It stands for “any valid credential”, so its placeholder owner is not checked. Seed a sign-in with a passkey its owner holds: Passkey::factory()->forAuthenticatable($user) gives it the user’s handle, while a bare Passkey::factory()->create() is refused.

Assertions

AssertionPasses when
assertRegistered()At least one registration was recorded.
assertRegisteredFor($user)A registration was recorded for $user.
assertNothingRegistered()No registration was recorded.
assertRegistrationCount($n)Exactly $n registrations were recorded.
assertAuthenticated()At least one successful authentication was recorded.
assertAuthenticatedFor($user)A successful authentication was recorded for $user.
assertAuthenticationFailed()At least one authentication failed.
assertAuthenticationCount($n)Exactly $n authentications were recorded.
assertRenamed(?$passkey, ?$name)A rename was recorded — of this passkey and/or to this name, when given.
assertNothingRenamed()No rename was recorded.
assertRevoked(?$passkey)A revocation was recorded — of this passkey, when given.
assertNothingRevoked()No revocation was recorded.

Assertions throw PasskeyAssertionFailed — a package exception, not a PHPUnit assertion — so they work under any runner. The fake honours AuthenticationExpectation the way the real verifier does, and for($user)->authenticate() holds the result to that account — a credential of the wrong owner is recorded as a failure and throws CredentialNotFound:

use Illuminate\Support\Facades\Event;
use RoundlyConsulting\Passkeys\Events\PasskeyRegistered;
use RoundlyConsulting\Passkeys\Exceptions\CredentialAlreadyRegistered;
use RoundlyConsulting\Passkeys\Models\Passkey;

// A rejected sign-in
$fake = Passkeys::fake()->rejectAuthentication();
$this->postJson('/login/passkey', $payload)->assertUnauthorized();
$fake->assertAuthenticationFailed();

// A registration failure surfaced to the client
$fake = Passkeys::fake()->failRegistrationWith(CredentialAlreadyRegistered::make());
$this->actingAs($user)->postJson('/passkeys', $payload)->assertStatus(422);
$fake->assertNothingRegistered();

// Sign-in resolves to a specific credential
$passkey = Passkey::factory()->es256()->forAuthenticatable($user)->create();
Passkeys::fake()->authenticatesAs($passkey);
$this->postJson('/login/passkey', $payload)->assertOk();

// A deleted or soft-deleted account can't sign in
$passkey = Passkey::factory()->es256()->forAuthenticatable($user)->create();
$fake = Passkeys::fake()->authenticatesAs($passkey);
$user->delete();
$this->postJson('/login/passkey', $payload)->assertUnauthorized();
$fake->assertAuthenticationFailed();

// Your listener runs on a fake registration
Event::fake([PasskeyRegistered::class]);
Passkeys::fake();
$this->actingAs($user)->postJson('/passkeys', $payload)->assertCreated();
Event::assertDispatched(PasskeyRegistered::class);

Real ceremonies with the virtual authenticator

When you want the real verifier in your suite — challenge, origin, RP ID hash, flags, signature, sign counter, user binding — drive it with VirtualAuthenticator, a software ES256 authenticator that attests with none:

use RoundlyConsulting\Passkeys\DataTransferObjects\AuthenticationOptionsOverrides;
use RoundlyConsulting\Passkeys\Enums\UserVerification;
use RoundlyConsulting\Passkeys\Facades\Passkeys;
use RoundlyConsulting\Passkeys\Testing\VirtualAuthenticator;

$authenticator = VirtualAuthenticator::es256();   // rpId from the options, origin from config
$keys = Passkeys::for($user);

$keys->register($authenticator->register($keys->registrationOptions()));

$passkey = $keys->authenticate($authenticator->assert($keys->authenticationOptions()));

// Prove your step-up really demands user verification:
$options = $keys->authenticationOptions(new AuthenticationOptionsOverrides(userVerification: UserVerification::Required));
$keys->authenticate($authenticator->assert($options, userVerified: false)); // UserVerificationRequired

$authenticator->assert($options, signCount: 3);   // explicit counter, e.g. a cloned key
$authenticator->credentialId();                   // base64url, as stored in credential_id
  • register($options, residentKey: false) models a non-discoverable credential, with no userHandle on assertions — so, as with a real browser, it can only answer options minted for its user (Passkeys::for($user)->authenticationOptions()); a usernameless ceremony refuses it.
  • The counter advances by one per assert() unless signCount is given; userVerified: false sends presence only.
  • It answers any options it is given, so a suite can also play the attacker and prove the server refuses.
  • Test-only: its key is throwaway material from crypto-for-laravel’s TestKeys, so never use it in production code. It lives in runtime autoload only so other packages’ suites can reach it — the same posture as Passkeys::fake(). On macOS with Homebrew PHP, key generation may need OPENSSL_CONF pointed at Homebrew’s openssl.cnf.

Factory

Passkey::factory() builds valid stored credentials for your own tests, using the configured model:

use RoundlyConsulting\Passkeys\Models\Passkey;

$passkey = Passkey::factory()->es256()->forAuthenticatable($user)->create();
$rsa     = Passkey::factory()->rs256()->neverUsed()->forAuthenticatable($user)->create();
StateEffect
es256()A valid ES256 (P-256) COSE public key.
rs256()A valid RS256 (RSA-2048) COSE public key.
withCredentialId($id)Keys the credential on an explicit ID and its hash.
forAuthenticatable($model)Owns the passkey by a specific model. A HasPasskeys model also gives it its user handle (minted on the model if it has none yet), as a real registration does; any other model keeps the random handle.
neverUsed()sign_count = 0, last_used_at = null.

Without forAuthenticatable(), a factory passkey’s owner is a placeholder (morph type user, a random ID) that no account matches, so a sign-in refuses it — under the fake and the real verifier alike.

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.