Second factor & owner expectations
After a password, magic-link or one-time-code login you know the account. Ask for its passkey through Passkeys::for($user), which holds the result to that account (AuthenticationExpectation::owner($user)):
use RoundlyConsulting\Passkeys\DataTransferObjects\AuthenticationOptionsOverrides;
use RoundlyConsulting\Passkeys\Enums\UserVerification;
use RoundlyConsulting\Passkeys\Facades\Passkeys;
// 1. Options for the known account, demanding user verification for this step only.
$options = Passkeys::for($user)->authenticationOptions(new AuthenticationOptionsOverrides(
userVerification: UserVerification::Required,
timeoutMs: 30_000,
));
// 2. Verify, and refuse any credential that is not this exact account's.
$passkey = Passkeys::for($user)->authenticate($response);AuthenticationOptionsOverrides sets userVerification and timeoutMs for one ceremony; null members fall back to config. The requirement is stored with the challenge, so the verifier enforces exactly what the options promised — a required step-up rejects a presence-only assertion even when the global default is preferred.
Multiple guards
With several guards whose models all own passkeys, restrict a usernameless login to one owner type:
use RoundlyConsulting\Passkeys\DataTransferObjects\AuthenticationExpectation;
$passkey = Passkeys::authenticate(
$response,
AuthenticationExpectation::ownerType((new Client)->getMorphClass()),
);How expectations behave
- AuthenticationExpectation::owner($user) — what Passkeys::for($user)->authenticate() applies — checks the morph type and the key (int, numeric-string, UUID and ULID keys); ::ownerType($morphClass) checks the type only. Pass either to the flat Passkeys::authenticate($response, $expect).
- The check runs right after the credential is located — before the challenge is consumed and before its counter is touched — so a mismatch writes nothing and the right owner can still finish the same ceremony.
- A mismatch is the uniform CredentialNotFound, so it reveals nothing about other accounts.
- owner() refuses an unsaved model with InvalidExpectation instead of silently widening to every account of that type.
Use $user->hasPasskeys() to offer the passkey step only to accounts that have an active passkey and can actually complete it.
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.