Weave Code
Weaver
Helps Laravel developers discover, compare, and choose open-source packages. See popularity, security, maintainers, and scores at a glance to make better decisions.
Feedback
Share your thoughts, report bugs, or suggest improvements.
Subject
Message

Laravel Passkeys Laravel Package

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.

View on GitHub
Deep Wiki
Context7

Getting Started

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.

Implementation Patterns

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

  • Usernameless / discoverable credentials (what the package does out of the box): no email field, the authenticator picks the credential. Great conversion, but requires the passkey to be created as a resident/discoverable credential and means the account lookup happens by credential_id.
  • Email-first / allow-list: ask for the email, then pass 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:

  • Multi-guard / admin panel — the default authentication action logs into the default guard and redirects. Point it at your admin guard and your own intended-URL logic.
  • Audit + notifications — wrap authenticate_using_passkey to fire an event, write an audit row, and send a "new sign-in with passkey" mail.
  • Rejecting old authenticators — inspect the credential/AAGUID in store_passkey and refuse attestation you don't want.
  • Step-up auth — a separate action that only confirms the user rather than logging them in, for re-auth before sensitive actions.

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.

Gotchas and Tips

Relying party ID and origins will bite you before anything else. Passkeys are cryptographically bound to a domain. Consequences:

  • A passkey created on 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.
  • Behind a load balancer or Heroku/Fly/Forge with TLS termination, Laravel may think the request is 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:

  • Unit/Livewire tests: 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.
  • Browser tests: Chrome DevTools Protocol exposes 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.
  • Feature-test the revocation endpoint hard: assert user A cannot delete user B's passkey.

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.

Weaver

How can I help you explore Laravel packages today?

Conversation history is not saved when not logged in.
Prompt
Add packages to context
No packages found.
novasa/laravel-language-center
opencage/geocode
adolfoholzer/parameters
livenetworks/ln-starter
shitoudev/permissionmanager
johannesclimacus/laravel-tui
hocvt/seotools
vitalybaev/sypexgeo
amphibee/themosis-ignition
gito-me/fast-excel
frankyso/laravel-ipaymu
geolize-studio/laravel-chile
boomerangui/chat
cgv/localized-resource
noith/settings
kda/laravel-package
kda/filament-dynamic-navigation
bypersoft/signbox
pairus/product-data
fayne/laravel-filament-cms-core