spatie/laravel-passkeys
Spatie's Laravel Passkeys package adds passwordless login to Laravel apps, with a Livewire component for generating and storing passkeys on user devices and a Blade component for authenticating users with those passkeys.
1. Install and scaffold
composer require spatie/laravel-passkeys
php artisan vendor:publish --tag="passkeys-migrations"
php artisan vendor:publish --tag="passkeys-config"
php artisan migrate
Livewire is a hard requirement — the registration UI ships as a Livewire component. If livewire/livewire isn't installed, install it first and make sure @livewireScripts / @livewireStyles (or @vite with Livewire's JS) are in your layout.
2. Opt your user model in
use Spatie\LaravelPasskeys\Traits\HasPasskeys;
class User extends Authenticatable
{
use HasPasskeys; // gives you $user->passkeys() and related helpers
}
The migration creates a passkeys table keyed by a morph (authenticatable_type / authenticatable_id), so it works for any guard model — but the package's own login flow assumes your default guard.
3. First use case: add a passkey from the profile page
Drop the Blade component onto an authenticated page (e.g. /profile/security):
{{-- resources/views/profile/security.blade.php --}}
<x-passkeys::generate-passkey />
That renders a "Create passkey" button which runs the WebAuthn registration ceremony, POSTs the attestation to the package's route, and stores a Passkey row with name, credential_id, data, and last_used_at. The name is derived from the authenticator (e.g. "MacBook Pro / Touch ID") so users can tell their keys apart.
4. First login use case: passwordless sign-in
On your login view:
<x-passkeys::authenticate-with-passkey />
This renders a button that triggers navigator.credentials.get() and authenticates the matched user on success, honouring redirect()->intended() (added in 1.0.8) so deep-linked guests land where they intended.
Where to look first: config/passkeys.php (actions, relying party, origins), then the Spatie\LaravelPasskeys\Livewire\* components and Spatie\LaravelPasskeys\Actions\* classes — those are the real extension surface; the Blade components are thin wrappers.
Passkey management page (list + revoke)
The package gives you the model; the UI you'll usually write yourself for control over naming and policy:
<x-passkeys::generate-passkey />
@forelse (auth()->user()->passkeys as $passkey)
<div>
{{ $passkey->name }}
<span>added {{ $passkey->created_at->diffForHumans() }}</span>
<span>last used {{ $passkey->last_used_at?->diffForHumans() ?? 'never' }}</span>
<livewire:passkeys::delete-passkey :passkey="$passkey" />
</div>
@empty
<p>No passkeys yet.</p>
@endforelse
Aim for a minimum of two registered keys before you let a user disable password login — a single passkey plus a lost device is an account lockout.
Passwordless login: two different UX shapes
credential_id.allowCredentials for that user's keys. This is an implementation pattern you build yourself by swapping the "generate authentication options" action and reading the email from the request.Choose one deliberately — mixing both in the same login form confuses users.
Non-Livewire frontends (Inertia, Vue, React)
Don't render the Blade component; use the package's JS helpers (startRegistration, startAuthentication) against the package's JSON endpoints, then hand the result to the action classes yourself. The Inertia path is documented upstream, and the JS helper signature was normalised in 1.x to the expected WebAuthn call structure — pin a version and read the docs for the exact shape.
const options = await fetch('/passkeys/authentication-options').then(r => r.json())
const credential = await startAuthentication(options)
await fetch('/passkeys/authenticate', {
method: 'POST',
headers: {
'Content-Type': 'application/json',
'X-CSRF-TOKEN': document.querySelector('meta[name=csrf-token]').content,
},
body: JSON.stringify(credential),
})
Swap actions instead of forking the package
Each ceremony step is a configurable action class:
// config/passkeys.php
'actions' => [
'generate_passkey_register_options' => App\Passkeys\RegisterOptions::class,
'store_passkey' => App\Passkeys\StorePasskey::class,
'generate_passkey_authentication_options' => App\Passkeys\AuthenticationOptions::class,
'authenticate_using_passkey' => App\Passkeys\AuthenticatePasskey::class,
],
Realistic reasons to swap:
admin guard and your own intended-URL logic.authenticate_using_passkey to fire an event, write an audit row, and send a "new sign-in with passkey" mail.store_passkey and refuse attestation you don't want.Keep the handle() signature identical to the class you replace — the Livewire components resolve these by class name from config, and a signature mismatch fails at runtime, not at boot.
remember me and session lifetime
The authentication entry point accepts a remember flag (1.4.0 / 1.5.0). Expose it as a checkbox next to the passkey button and pass it through:
<input type="checkbox" wire:model="remember" id="remember">
<label for="remember">Remember me</label>
Tune the ceremony with authenticator selection
Since 1.1.0 the register-options action sets authenticator selection criteria, and 1.5.0 added a configurable ceremony-step-manager action. Day-to-day this means:
residentKey: required if you want usernameless login.userVerification: preferred for most apps; required if you're using passkeys as a second factor.authenticatorAttachment: platform to steer users to Touch ID / Windows Hello, or leave it unset to also allow YubiKeys.Do this by overriding the register-options action, not by editing vendor code.
Integration tip: layer passkeys in, don't flip a switch
Ship in this order: (1) let users add passkeys, (2) show a "Sign in with passkey" button next to the password form, (3) after N successful passkey logins, prompt to remove the password. You keep the password reset flow alive the whole time as the recovery path.
Relying party ID and origins will bite you before anything else. Passkeys are cryptographically bound to a domain. Consequences:
http://localhost is worthless on production; every dev has to re-register. Expected, not a bug.allowed_origins must match the exact origin including scheme and port (https://app.example.com, not app.example.com). 1.3.0 made this configurable — use it for www/non-www, staging aliases, or a second app domain.http. The ceremony then fails the origin check with a vague verification error. Fix by configuring TrustProxies and/or URL::forceScheme('https') in a service provider — the package derives the default origin from APP_URL/request, so an inconsistent APP_URL (trailing http://) breaks registration silently.WebAuthn requires a secure context. HTTP works only for localhost. Testing on a LAN IP (192.168.x.x) or a .test domain served over plain HTTP will make navigator.credentials throw NotAllowedError with no useful message — that's the browser, not the package. Use HTTPS (Valet/Herd/Sail with TLS) for device testing.
"Cancel" and timeout are normal outcomes. Users dismissing the OS prompt, no matching credential, and timeouts all surface as NotAllowedError. Always handle the rejection in JS and keep the password form usable; don't treat it as an application error in your logs.
data is not a plain array. The data column holds a serialized credential; the accessor cast changed type in webauthn-lib 5.3 (CredentialRecord instead of PublicKeyCredentialSource) and 1.6.5 fixed the resulting type errors. Never read $passkey->data as an array in your own code, and pin web-auth/webauthn-lib deliberately when upgrading — a minor bump there has broken this package's accessors before.
Multi-guard apps need work. The default flow authenticates the default guard and redirects to a config-level URL. Admin/staff portals must override the authentication action (or write a guarded controller calling the actions directly), or you'll log admins into the customer session.
Don't let passkey creation be a free action. Adding a passkey is a persistent credential — treat it like changing a password. Require password.confirm (or a fresh login) before rendering <x-passkeys::generate-passkey />, and authorise that the passkey being deleted belongs to auth()->user() — the package's default UI is a convenience, not a policy.
Account recovery is your problem, not the package's. There is no "forgot passkey" flow. Keep email-based reset, and consider recovery codes. Also send a notification whenever a passkey is added or removed — it's the cheapest defence against a hijacked session planting a permanent key.
Rate limit and log the auth endpoint. Passkey login is phishing-resistant but the options endpoint still leaks whether a credential exists; throttle it and log failures.
Localisation is built in and cheap. Translations for ~15 locales ship with the package (Turkish, Arabic, Chinese, Hungarian, Portuguese, German, French, Dutch, Czech/Slovak, Polish). Publish and override:
php artisan vendor:publish --tag="passkeys-translations"
then edit lang/vendor/passkeys/{locale}/passkeys.php. Don't hardcode English strings in your own components around it.
Testing. You cannot fake a real authenticator in a PHPUnit test, so split your coverage:
Livewire::test(GeneratePasskey::class) with the actions swapped in config('passkeys.actions') to fakes that return canned options and accept canned attestations. Assert the Passkey row is created against the right user.WebAuthn.addVirtualAuthenticator, which makes end-to-end register → logout → login testable in Dusk/Playwright/Pest browser mode. This is the only way to catch origin and relying-party misconfiguration, so run at least one such test in CI.Extension points worth knowing: the configurable action classes, the Passkey model (extend it and point config('passkeys.models.passkey') at your subclass if you need extra columns like device_label or last_ip), and the published migration (add your own indexes — you'll want one on credential_id since usernameless login looks credentials up by that value).
Version sanity: 1.6.x supports Laravel 13 and Livewire 3/4, so this is safe on a modern app. Note the dependents count is 0 and the package is still 1.x — read the changelog before minor bumps, because several releases (1.0.1, 1.5.0, 1.6.5) changed action signatures or type hints in ways that can break a customised action without a major-version flag.
How can I help you explore Laravel packages today?