Ditching Passwords: Setting Up Passkeys in Laravel 13

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

laravel
security
authentication
passkeys
webauthn
Steven Richardson

Steven is a software engineer with a passion for building scalable web applications. He enjoys sharing his knowledge through articles and tutorials.