Ditching Passwords: Setting Up Passkeys in Laravel 13

Ditch passwords in Laravel 13: install spatie/laravel-passkeys, publish the migration, add Livewire components, and test with Chrome's virtual authenticator.

Steven Richardson
Steven Richardson
· 8 min read

Passwords get phished. Password reset flows get abused. Users reuse the same credentials across twelve different sites. Passkeys sidestep all of this: a private key never leaves the user's device, authentication is a cryptographic challenge that can't be replayed, and there's nothing on the server for an attacker to dump. Laravel 13 handles them cleanly via spatie/laravel-passkeys — here's the full setup from composer require to a working login flow.

How Laravel Passkeys Work (WebAuthn in 90 Seconds)#

The WebAuthn spec defines two operations: attestation (creating a credential at registration) and assertion (proving possession of it at login).

During registration, the browser generates a public/private key pair on the device. The public key goes to your server. The private key stays inside the password manager — iCloud Keychain, 1Password, a hardware security key — and never touches the network.

During login, your server sends a random challenge. The browser signs it with the stored private key and returns the signature. Your server verifies that signature against the stored public key. No password transmitted, no shared secret to intercept, no credential database worth stealing.

The spatie/laravel-passkeys package (v1.7.0, April 2026) handles the server-side cryptography and ships ready-made Livewire and Blade components for both flows. The browser side uses @simplewebauthn/browser — a slim wrapper around the native WebAuthn API.

Laravel Passkeys Setup: Requirements#

  • PHP 8.2 – 8.4
  • Laravel 11 – 13
  • Livewire 3.5 or 4.0
  • Node and npm (for the JS browser library)

If you're on Laravel 12 and haven't jumped to 13 yet, the Laravel 12 to 13 upgrade guide covers the three breaking areas and the new features worth picking up while you're in there.

Installing the Package#

composer require spatie/laravel-passkeys

Publish and run the migration:

php artisan vendor:publish --tag="passkeys-migrations"
php artisan migrate

This creates a passkeys table storing each user's public key credential, credential ID, and the AAGUID (authenticator attestation GUID — used to identify the type of authenticator: platform vs cross-platform, hardware key vs software).

Install the browser library and rebuild assets:

npm install @simplewebauthn/browser && npm run build

The package needs this to drive navigator.credentials.create() during registration and navigator.credentials.get() during login.

Wiring Up the User Model#

Your User model (or any authenticatable model) gets the interface and trait:

<?php

namespace App\Models;

use Illuminate\Foundation\Auth\User as Authenticatable;
use Spatie\LaravelPasskeys\Models\Concerns\HasPasskeys;
use Spatie\LaravelPasskeys\Models\Concerns\InteractsWithPasskeys;

class User extends Authenticatable implements HasPasskeys
{
    use InteractsWithPasskeys;

    // ... rest of your model
}

Then register the passkey routes in routes/web.php:

use Illuminate\Support\Facades\Route;

// Registers challenge generation, credential storage, and assertion endpoints
Route::passkeys();

That one macro adds all the routes the package needs. You don't build the WebAuthn endpoints yourself.

Registration Flow: Adding a Passkey#

Place the Livewire component on a page the user is already authenticated on — a profile or security settings page:

{{-- resources/views/profile/security.blade.php --}}
<div>
    <h2>Passkeys</h2>

    {{-- Lists existing passkeys and provides an "Add passkey" button --}}
    <livewire:passkeys />
</div>

When the user clicks "Add passkey" the component:

  1. Calls your server to generate a registration challenge
  2. Passes the challenge options to navigator.credentials.create() via @simplewebauthn/browser
  3. Sends the attestation response (containing the new public key) back to Laravel
  4. Stores the credential in the passkeys table associated with the authenticated user

The component also lists the user's registered passkeys with a delete button per entry, so managing multiple devices works out of the box.

Login Flow: Authenticating With a Passkey#

On the login page, add the Blade component alongside your password form:

{{-- resources/views/auth/login.blade.php --}}
<div>
    {{-- Existing password form --}}
    <form method="POST" action="/login">
        @csrf
        <input type="email" name="email" placeholder="Email" />
        <input type="password" name="password" placeholder="Password" />
        <button type="submit">Sign in with password</button>
    </form>

    <hr>

    {{-- Passkey authentication — works alongside the password form --}}
    <x-authenticate-passkey />
</div>

The <x-authenticate-passkey /> component handles the complete assertion flow:

  1. Requests an authentication challenge from the server
  2. Calls navigator.credentials.get() with the challenge options
  3. The user authenticates via Face ID, fingerprint, PIN, or hardware key — whatever's registered
  4. Posts the signed assertion to Laravel's authentication endpoint
  5. On success, a session is created and the user is redirected

If a user hasn't registered a passkey, the component renders nothing interactive. They fall through to the password form without any awkward empty state to handle.

Configuration#

Publish the config file to customise redirect behaviour and relying party settings:

php artisan vendor:publish --tag="passkeys-config"

The key values in config/passkeys.php:

return [
    // Where to redirect after a successful passkey login
    'redirect_to_after_login_using_passkey' => '/dashboard',

    // Relying Party — the RP ID must match your domain exactly
    'relying_party_name' => env('APP_NAME', 'Laravel'),
    'relying_party_id'   => env('APP_DOMAIN', 'localhost'), // e.g. 'example.com' — no scheme
    'relying_party_url'  => env('APP_URL', 'http://localhost'),

    // Passkey Eloquent model — override to extend with custom behaviour
    'passkey_model' => Spatie\LaravelPasskeys\Models\Passkey::class,
];

Add APP_DOMAIN to your .env with your bare domain (example.com, not https://example.com). The relying party ID must exactly match the effective domain of the origin — any mismatch and the browser refuses to create or use the credential.

Testing Locally With Chrome DevTools#

You don't need a hardware key or a phone to test this. Chrome ships a virtual authenticator:

  1. Open DevTools (F12) → Application tab → scroll to WebAuthn in the left sidebar
  2. Check Enable virtual authenticator environment
  3. Click Add authenticator — pick ctap2, transport internal, enable Supports user verification
  4. Visit your profile page and click "Add passkey" — the virtual authenticator handles the credential creation
  5. Log out, go to the login page, click the passkey button — Chrome signs the assertion using the virtual key

The virtual authenticator persists while DevTools is open. Close it and everything resets, which is handy for testing fresh-registration flows. Firefox doesn't support the virtual authenticator panel, but Chrome's implementation covers all the cases you need for development.

Gotchas and Edge Cases#

HTTPS is mandatory in production. WebAuthn requires a secure context. localhost qualifies as secure for local development, but any deployed environment — staging included — must serve over HTTPS. Passkey operations silently fail on HTTP with a NotAllowedError that gives no indication of the real cause.

RP ID mismatch breaks registration and login. If relying_party_id is set to example.com but your app loads from staging.example.com, the browser rejects the credential. Use the common domain root (example.com) as the RP ID — the spec permits this and credentials registered with it work from any subdomain.

Livewire is a hard dependency for the built-in management UI. The <livewire:passkeys /> component requires Livewire. If your app doesn't use Livewire, you can still use Route::passkeys() endpoints directly and build a custom UI with fetch() calls. The API endpoints are fully documented in the package.

Session driver matters across multiple servers. The WebAuthn challenge is stored server-side between the challenge generation request and the assertion POST. If you're running multiple app servers without sticky sessions, the request that gets the challenge and the request that verifies it may land on different servers. Ensure your session driver is database or redis, not file. The same applies to fine-grained rate limiting on your auth endpoints — phishing-resistant doesn't mean unlimited attempts should be permitted.

iCloud Keychain sync latency. On iOS and macOS, passkeys sync through iCloud Keychain. Register on one device and immediately try another — there can be a few seconds delay before the credential appears. Not a bug, just sync lag. Worth knowing so you don't mistake it for a registration failure during testing.

Wrapping Up#

The full setup is: composer require, model trait, migration, Route::passkeys(), one Livewire component on the profile page, one Blade component on the login page. The package handles the WebAuthn cryptography — attestation formats, CBOR decoding, challenge verification — so you don't have to reason about any of it.

Password fallback works without extra configuration: <x-authenticate-passkey /> sits next to your existing password form and users choose whichever they prefer. In practice, once users add a passkey and use it a couple of times, most stop reaching for the password input.

For more on tightening up your Laravel codebase, Pest architecture testing rules let you enforce authentication guards and middleware across the entire app automatically — a fast way to catch unguarded routes before they reach production. The complete Laravel developer toolchain for 2026 is worth a read if you're building out a security-conscious stack from scratch.

FAQ#

What happens if a user's device is lost or stolen?

Since the private key never leaves the device, a stolen phone doesn't compromise the account if the device is locked. The attacker would need to unlock the phone and authenticate with biometrics or PIN. More importantly, you can delete that credential from the passkey management UI without affecting other devices where the user has registered a passkey.

Do I need to support password login alongside passkeys forever?

Not necessarily, but it's smart to during the transition. Keep the password form available so users who haven't adopted passkeys yet can still log in. Once your user base has adopted them, you can eventually deprecate passwords. The package supports password-free setups — the Blade component renders nothing if no passkeys exist, so migrations can be gradual.

Can passkeys work across multiple subdomains?

Yes, if you set the relying party ID to the common domain root. For example, if your app runs on app.example.com, api.example.com, and staging.example.com, set APP_DOMAIN=example.com. The spec permits RP ID to match the domain suffix, so any subdomain can use credentials registered with the parent.

What if the user has multiple devices and registers a passkey on each?

The <livewire:passkeys /> component lists all registered credentials with a delete button per entry, so managing multiple devices is straightforward. You can authenticate with any registered passkey — the browser/device combination handles which one to use.

Do I need to validate AAGUID in production?

The AAGUID identifies the authenticator type (hardware key, platform authenticator, etc.). You can use it to enforce policy — "only hardware keys allowed" or "platform authenticators only" — but it's optional. Most setups don't validate it; the security comes from the cryptography, not the authenticator type.

Steven Richardson
Steven Richardson

CTO at Digitonic. Writing about Laravel, architecture, and the craft of leading software teams from the west coast of Scotland.