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

Passkeys Laravel Package

laravel/passkeys

Laravel Passkeys adds passwordless WebAuthn authentication to Laravel apps, with built-in routes for passkey login, confirmation, and management, configurable relying party settings, custom model support, and a matching @laravel/passkeys JavaScript client.

View on GitHub
Deep Wiki
Context7

Getting Started

Four commands and one trait get you a working endpoint:

composer require laravel/passkeys
php artisan vendor:publish --tag=passkeys-migrations
php artisan migrate
php artisan vendor:publish --tag=passkeys-config
use Laravel\Passkeys\Contracts\PasskeyUser;
use Laravel\Passkeys\PasskeyAuthenticatable;

class User extends Authenticatable implements PasskeyUser
{
    use PasskeyAuthenticatable;
}

The server half is done at that point. The browser half is a separate install — this package ships no JavaScript:

npm install @laravel/passkeys
import { Passkeys } from '@laravel/passkeys'

await Passkeys.register({ name: 'MacBook Pro' })  // authenticated user enrols a device
await Passkeys.verify()                            // guest logs in

Where to look first, in this order:

  1. config/passkeys.php. Read all of it; change four keys. relying_party_id and allowed_origins are the two that break local development, and guard, management_middleware, throttle, and redirect are the ones you'll actually tune. user_handle_secret deserves its own look — see Gotchas.
  2. php artisan route:list | grep passkey. Seven routes appear silently under web, including three under /user/passkeys/*. Confirm that prefix doesn't collide with a resource route you already own.
  3. The PasskeyAuthenticatable trait. It assumes name and email columns. getPasskeyDisplayName() falls back name → email → auth ID; getPasskeyUsername() falls back email → auth ID. Override both if your authenticator picker should show a handle rather than a legal name.

First use case — ship enrolment, not login. Add a "Passkeys" panel to your existing account settings page. Authenticated users register a device, you list credentials, they revoke them. Passwords still work, nothing in the login path changes, and you get real enrolment numbers before committing to passwordless-first. Flipping on the login button later is a one-line addition to the guest page.

Implementation Patterns

Treat passkeys like API tokens, not like a password. They are per-device, they silently stop working when a laptop is replaced, and users lose them without noticing. That means a list with device names, a "Add this device" button, and a delete action hitting DELETE /user/passkeys/{passkey}. Prompt for a name at registration — it's what the OS account picker displays months later when the user has three credentials and no memory of which is which. The bundled AAGUID catalog will help you label them ("YubiKey 5", "iCloud Keychain") without asking.

Login as progressive enhancement, not replacement. Put the passkey button above the password form. Passkeys.verify() → POST /passkeys/login → the package authenticates the user against the configured guard. Keep the password form underneath. Conditional UI (autofill-style passkey suggestions) is the good UX, but a plain button that fails gracefully is a fine v1.

Use the confirm routes for step-up auth, not the login routes. /passkeys/confirm re-verifies the currently authenticated user for sensitive actions without logging them in again. This is the flow for changing an email, rotating API keys, or approving a payment. Wire it to the password.confirm gates you already have.

For manual 2FA-style verification, drop to the actions. The README's user-bound pattern is the one to copy when you need ownership enforcement outside the packaged routes:

$options = app(GenerateVerificationOptions::class)($request->user());

$passkey = app(VerifyPasskey::class)(
    $request->credential(),
    $options,
    $request->user(),
);

Passing the user into VerifyPasskey scopes the assertion to that user's credentials — this is what prevents one user's assertion being replayed into another's session.

Use the events for audit, not for auth. PasskeyRegistered, PasskeyVerified, PasskeyDeleted fire at the right moments. Log them to your audit trail, notify the user on registration ("a new device was added to your account"), and email on deletion. Don't put authorization logic in a listener — the authorizeLoginUsing callback exists for exactly that:

Passkeys::authorizeLoginUsing(function (Request $request, PasskeyUser $user, Passkey $passkey): bool {
    if ($user->is_banned) {
        throw ValidationException::withMessages([
            'credential' => ['This account has been banned.'],
        ]);
    }

    return true;
});

Inertia/SPA: bind a custom login response. The default redirects to config('passkeys.redirect'). A SPA wants JSON:

class MyLoginResponse implements PasskeyLoginResponse
{
    public function toResponse($request)
    {
        return response()->json(['redirect' => '/dashboard']);
    }
}

$this->app->singleton(PasskeyLoginResponse::class, MyLoginResponse::class);

There are four contracts — login, confirmation, registration, deleted — and you'll typically want all four if you're not in Blade.

Constrain authenticators in the registration action. If you want "this device only" (Touch ID / Face ID / Windows Hello) rather than allowing roaming hardware keys, extend GenerateRegistrationOptions and override authenticatorSelection(), then bind it. Same pattern for any of the five actions.

Gotchas and Tips

relying_party_id is a bare hostname — no scheme, no port, no trailing slash. https://app.test in that field will fail every ceremony with an opaque error. The default (parse_url(config('app.url'), PHP_URL_HOST)) is correct, but it silently follows APP_URL, so a stale .env breaks passkeys first.

allowed_origins takes full origins. Scheme and port included. http://localhost:8000 and http://127.0.0.1:8000 are different origins to WebAuthn. Pick one and stick to it in your dev workflow, or both go in the array.

HTTPS is mandatory outside of localhost. A staging environment on plain HTTP will not work regardless of configuration.

user_handle_secret must be stable and permanent. It derives the opaque user handle stored on the credential — the primary key is never exposed to authenticators, which is the right design, but it means rotating this secret orphans every existing passkey. Don't point it at APP_KEY and then rotate APP_KEY during a security incident; set a dedicated PASSKEYS_USER_HANDLE_SECRET and treat it as append-only.

management_middleware defaults to ['password.confirm']. Sensible, but it means a freshly-registered user can't add a passkey from the settings page without re-entering their password — and it quietly assumes you have password.confirm configured. If your app uses a different step-up mechanism (or your settings page is already behind it), set this to [] and enforce re-auth yourself.

Verification uses a transaction with pessimistic locking (added in v0.2.0). That's replay protection on the signature counter. Don't wrap VerifyPasskey in your own transaction — nesting behaves differently across drivers and the lock is doing real work.

Sync passkeys blur the "one credential per device" model. iCloud Keychain and Google Password Manager replicate a single credential across devices. The passkey list will show one entry for what the user thinks of as four devices. Don't build UI that says "you have 4 devices" — say "you have 2 passkeys".

Overriding the user model has sharp edges. Passkeys::useUserModel() and Passkeys::usePasskeyModel() work, but the trait assumes standard name/email columns, and the passkeys relation was fixed for custom user keys only in v0.2.0. If your primary key isn't id or your email column is email_address, override getPasskeyDisplayName(), getPasskeyUsername(), and getAuthIdentifier() — and pin the package version.

Set a dedicated route prefix if you own /user/*. Passkeys::ignoreRoutes() plus your own route file is the escape hatch, but it means re-registering seven routes and their middleware. Cheaper to rename your existing resource route.

Testing WebAuthn end-to-end is not worth it. Faking signature assertions means reimplementing the spec. Test the layer above: bind a fake VerifyPasskey in the container and assert your controller's authorization callback, redirect, event dispatch, and response shape. Leave the cryptography to the package's own suite (composer test).

Version pin. This is 0.x with ~116 stars and a small contributor base. webauthn-lib v5.3 is a real dependency upgrade risk, and v0.2.1's "omit null values from options" fix suggests the JSON contract was still settling. Pin to a minor and read the changelog before bumping.

Detect support before showing the button. window.PublicKeyCredential is undefined on older browsers, and passkeys are unavailable in many embedded webviews (in-app browsers on iOS and Android in particular). Graceful degradation isn't optional here — the password form is the fallback.

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.
vento/laravel-airbrake
wfeller/laravel-batch
maco-studios/mage-os-installer
busarm/armie
siberfx/laravel-logger
kfteam1991/dcat-admin
dashed/dashed-laposta
dashed/dashed-ecommerce-montaportal
dashed/dashed-ecommerce-etsy
neohq/string-blade-compiler
log1x/acf-composer
cgv/localized-resource
noith/settings
kda/laravel-package
kda/filament-dynamic-navigation
bypersoft/signbox
pairus/product-data
fayne/laravel-filament-cms-core
nodisrupt/laravel-agent
sierratecnologia/media-manager