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

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);                 // the browser parses publicKey for 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 WebAuthn JSON form — publicKey is a PublicKeyCredentialRequestOptionsJSON with binary members base64url-encoded. The browser converts it with PublicKeyCredential.parseRequestOptionsFromJSON() before navigator.credentials.get(), and 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": []
  }
}

The browser side

The same steps as for registration, with parseRequestOptionsFromJSON() and navigator.credentials.get() — post credential.toJSON() back with the ceremonyId:

const options = await (await fetch('/login/passkey/options')).json();
const credential = await navigator.credentials.get({
    publicKey: PublicKeyCredential.parseRequestOptionsFromJSON(options.publicKey),
});
await fetch('/login/passkey', { method: 'POST', headers: { 'Content-Type': 'application/json' },
    body: JSON.stringify({ ...credential.toJSON(), ceremonyId: options.ceremonyId }) });

credential.toJSON() does not carry the ceremonyId, so the front end adds it; without it the ceremony fails with ChallengeExpired. Older browsers lack parseRequestOptionsFromJSON() and toJSON(): where they are missing, convert challenge and each allowCredentials[].id from base64url to an ArrayBuffer before the call, then base64url-encode rawId and the response members before posting.

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.
  • The credential must still belong to its owner — checked before the challenge is consumed and before any write. The owner must still exist (a hard- or soft-deleted one does not) and its stored user handle must equal the credential’s, in constant time, so an account that reuses a deleted account’s ID never inherits its passkeys. An owner type that no longer names a concrete model — a renamed account class, a morph alias dropped from the map, an abstract model — counts as a missing owner, never a raw Error; map the old type to the current class to restore those passkeys. Each failure is the uniform CredentialNotFound.
  • 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 optional

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.