---
title: "Authentication — Passkeys for Laravel | Roundly"
description: "Usernameless or user-bound sign-in — build request options, verify the assertion and start your own session from the resolved credential."
url: https://roundly-consulting.com/open-source/docs/passkeys-for-laravel/authentication
language: en
---

[All packages](https://roundly-consulting.com/open-source.md)

[Passkeys for Laravel](https://roundly-consulting.com/open-source/docs/passkeys-for-laravel.md)

# 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:

```php
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:

```json
{
  "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:

```js
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:

```php
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):

```php
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](https://roundly-consulting.com/support-us.md)

By donating, you agree to our [donation terms](https://roundly-consulting.com/donation-terms.md).

[Support our open source work (opens in a new tab)](https://donate.stripe.com/dRmeVe8FX5PF1Qd9pXcEw00) [Join us on Patreon (opens in a new tab)](https://www.patreon.com/cw/roundly)

## 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.

[Get a quote in 48 hours](https://roundly-consulting.com/contact.md) [Browse all packages](https://roundly-consulting.com/open-source/docs/passkeys-for-laravel.md)
