Authentication
Signing in is two calls as well — request options out, assertion in. The package resolves the credential and its owner; your application then issues its own session or token:
use RoundlyConsulting\Passkeys\Facades\Passkeys;
use RoundlyConsulting\Passkeys\DataTransferObjects\AuthenticationResponseData;
// 1. Server -> client: request options (usernameless — any account's passkey can answer).
$options = Passkeys::authenticationOptions(); // or Passkeys::for($user)->authenticationOptions()
return response()->json($options); // feed publicKey to navigator.credentials.get()
// 2. Client -> server: verify the assertion; the resolved credential's owner is reachable
// via $passkey->authenticatable. The host then issues its own session or token.
$passkey = Passkeys::authenticate(
AuthenticationResponseData::fromArray($request->validated()),
);
$user = $passkey->authenticatable;Usernameless or user-bound
- No user — allowCredentials is empty, so the authenticator offers any discoverable passkey for your RP and reports its user handle. The response must carry that userHandle (WebAuthn §7.2 step 6); one without it gets the uniform CredentialNotFound.
- A user, through Passkeys::for($user)->authenticationOptions() — allowCredentials lists that user’s credentials, and the ceremony is bound to them: only a credential it offered can complete it. Another account’s passkey, or one enrolled after the options were issued, gets the same uniform CredentialNotFound as an unknown credential.
authenticationOptions() returns a RequestOptionsData, JsonSerializable into the shape navigator.credentials.get({ publicKey }) expects. The ceremonyId travels back with the response so a stateless host can correlate the challenge:
{
"ceremonyId": "…40 chars…",
"publicKey": {
"challenge": "…b64url…",
"rpId": "example.com",
"timeout": 60000,
"userVerification": "required",
"allowCredentials": []
}
}Signing the user in
AuthenticationResponseData::fromArray() reads rawId (or id), ceremonyId, response.clientDataJSON, response.authenticatorData, response.signature and the optional response.userHandle. The package never logs anyone in — that stays your job:
use RoundlyConsulting\Passkeys\DataTransferObjects\AuthenticationResponseData;
$response = AuthenticationResponseData::fromArray($request->all());
$passkey = Passkeys::authenticate($response); // Passkey
// The host owns login — start its own session/token:
Auth::login($passkey->authenticatable);What authenticate() verifies
- The credential is located by its ID; any miss is a uniform CredentialNotFound, so registered users can’t be enumerated. A present userHandle must match the stored one in constant time.
- An AuthenticationExpectation, if given, is checked next — before the challenge is consumed and before any write.
- clientDataJSON decodes and its type is webauthn.get.
- The challenge was minted for authentication and matches in constant time; a user-bound challenge also requires the credential to belong to that user and be one it offered, and a usernameless one requires the response’s userHandle.
- The origin allow-list and cross-origin policy hold.
- The RP ID hash matches; user presence is set, user verification is set when the ceremony requires it, a backed-up credential is backup-eligible, and backup eligibility still equals the value stored at registration (WebAuthn L3 §7.2 step 19).
- The signature over authenticatorData ‖ sha256(clientDataJSON) verifies against the stored public key.
- The sign counter is reconciled, the backup state is recorded and PasskeyAuthenticated fires.
Sign-counter reconciliation
sign_count guards against cloned authenticators. The counter only ever moves forward, and the database decides: it advances in one conditional UPDATE … WHERE sign_count < ?, so neither a regression nor two concurrent assertions can write a lower counter back. On each successful assertion:
- A higher counter advances sign_count; the assertion’s backup state and last_used_at are written in the same statement.
- When both the stored and received counters are 0 (authenticators without a counter), usage is recorded — but only while the stored counter is still 0.
- A counter that did not advance follows sign_count_policy: reject throws SignCountRegression and aborts; flag (the default) dispatches PasskeySignCountRegressed, records the backup state and last_used_at without touching the counter, and lets the sign-in through.
- The stored counter is never lowered, so under flag a cloned authenticator is flagged on every assertion it makes, not just the first.
- A credential revoked while the assertion was being verified fails with CredentialNotFound.
Using the actions directly
The flat and the user-bound calls share the same two actions; the user and the expectation are optional (see DI and actions):
use RoundlyConsulting\Passkeys\Actions\GenerateAuthenticationOptionsAction;
use RoundlyConsulting\Passkeys\Actions\VerifyAuthenticationAction;
$options = app(GenerateAuthenticationOptionsAction::class)->execute($user, $overrides); // both optional
$passkey = app(VerifyAuthenticationAction::class)->execute($response, $expectation); // expectation optionalShow 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.