Originally published at hafiz.dev
For a long time, adding passkeys to a Laravel app meant reaching for a third-party package, assembling WebAuthn ceremonies by hand, or piecing together a tutorial that assumes you already know what a "relying party ID" is. That's done.
In late April 2026, Laravel shipped laravel/passkeys, a first-party package authored by Taylor Otwell that gives you a complete passkey story out of the box. Server package, npm client, Fortify integration. Three pieces that click together so passwordless auth is boring to wire up, which is exactly what you want from a security feature.
I covered the Spatie passkeys approach back in January, and that's still valid if you're Livewire-heavy or already have that package running. But the native package is the right call for new projects and anything using Fortify. Here's the full setup.
What Ships in laravel/passkeys
The passkey stack has three components that each handle a distinct concern.
laravel/passkeys is the server-side Composer package. It handles WebAuthn ceremonies, manages a passkeys database table, registers routes for login, confirmation, and credential management, and fires events you can hook into. If you need custom authorization logic or your own route definitions, escape hatches are built in.
@laravel/passkeys is the npm client. It handles browser-side ceremony coordination (registration and verification) and ships first-class helpers for React, Vue, and Svelte with SSR-safe hooks so client-only APIs don't fight your framework. The public API is two methods: Passkeys.register() and Passkeys.verify(). That's it.
Fortify integration wires everything together via Features::passkeys() in your app config and a passkeys section in config/fortify.php. Fortify apps get the same endpoints and the PasskeyUser and PasskeyAuthenticatable contracts without reimplementing any glue.
The package is v0.1.0 but that's not a red flag. It's already the default in Laravel's official starter kits and used by Fortify in production. The version number signals that the public API may still evolve, not that the package is unstable.
Installation
Start by pulling in the Composer package:
composer require laravel/passkeys
Publish and run the migrations to create the passkeys table:
php artisan vendor:publish --tag=passkeys-migrations
php artisan migrate
Next, add a secret to your .env for deriving stable opaque user handles. This keeps passkey associations private even if your user IDs are sequential integers:
PASSKEYS_USER_HANDLE_SECRET=your-random-secret-here
Generate a value with:
php artisan key:generate --show
Use that output as your secret. The package falls back to APP_KEY if you leave this blank, but keeping them separate is better practice. If you ever rotate your app key, users won't lose their passkeys. You can find a full reference of available artisan commands in the Laravel Artisan Commands reference.
Configuring Your User Model
Add the PasskeyUser contract and PasskeyAuthenticatable trait to your User model:
<?php
namespace App\Models;
use Illuminate\Foundation\Auth\User as Authenticatable;
use Laravel\Passkeys\Contracts\PasskeyUser;
use Laravel\Passkeys\PasskeyAuthenticatable;
class User extends Authenticatable implements PasskeyUser
{
use PasskeyAuthenticatable;
// Rest of your model...
}
The trait assumes your users table has name and email columns. Authenticators show these values in their UI during registration and account selection. displayName falls back from name to email to the auth identifier. Same for username.
If you need different display values, override the methods directly on the model:
public function getPasskeyDisplayName(): string
{
return $this->full_name ?? $this->email;
}
public function getPasskeyUsername(): string
{
return $this->email;
}
That's the only change your model needs. No extra migrations, no pivot tables. The passkeys table handles credential storage and links to your user via a standard relationship that PasskeyAuthenticatable sets up for you.
Fortify Integration
If you're using Laravel Fortify, enabling passkeys takes one line in your features array:
use Laravel\Fortify\Features;
'features' => [
Features::registration(),
Features::resetPasswords(),
Features::emailVerification(),
Features::passkeys(), // Add this
],
Fortify automatically registers the passkey routes and wires up the contracts. Nothing else changes on the server side. Your existing authorization setup with policies and gates stays untouched: passkeys only replace the authentication step, not what happens after it.
The Config File
Publish the config if you need to customize anything:
php artisan vendor:publish --tag="passkeys-config"
The defaults in config/passkeys.php are sensible:
return [
'relying_party_id' => parse_url(config('app.url'), PHP_URL_HOST),
'allowed_origins' => [config('app.url')],
'user_handle_secret' => env('PASSKEYS_USER_HANDLE_SECRET', config('app.key')),
'timeout' => 60000,
'guard' => 'web',
'middleware' => ['web'],
'management_middleware' => ['password.confirm'],
'throttle' => 'throttle:6,1',
'redirect' => '/',
];
A few worth understanding before you change anything.
relying_party_id is your domain, derived from APP_URL. Passkeys are cryptographically bound to this value. If the domain the browser accesses doesn't match, the ceremony fails. Make sure APP_URL reflects the actual domain you're serving, especially in local development.
management_middleware defaults to password.confirm, which means users must re-confirm their password before adding or revoking passkeys. Don't disable this. It's the right friction for a security-critical action. The same principle applies here as with sensitive token operations in Passport vs Sanctum.
throttle limits passkey attempts to 6 per minute. Sensible for production. Adjust it if you have unusual traffic patterns, but don't remove it entirely.
Routes the Package Registers
You don't define any routes yourself. The server package registers these automatically:
POST /passkeys/register/options (generate registration challenge)
POST /passkeys/register (store the new credential)
POST /passkeys/verify/options (generate authentication challenge)
POST /passkeys/verify (authenticate with passkey)
DELETE /passkeys/{passkey} (revoke a specific passkey)
If you need custom route definitions (different middleware, prefixes, or custom controllers), you can disable auto-registration in the config and define them yourself. The underlying action classes are all public and importable, so you're not losing functionality by taking manual control.
How the WebAuthn Flow Works
It helps to see the ceremony before writing the frontend code:
Registration follows the same pattern: browser requests options, authenticator creates a key pair, public key gets stored on your server. Nothing sensitive ever leaves the device. The private key never travels over the network, which is the core security advantage over passwords. No credentials to steal from a database breach.
Frontend Integration (Vue)
Install the npm client:
npm install @laravel/passkeys
npm run build
Here's a Vue 3 component that handles both registration (authenticated users adding a passkey) and login (on the login page):
<script setup>
import { ref } from 'vue'
import { Passkeys } from '@laravel/passkeys'
const registering = ref(false)
const verifying = ref(false)
const error = ref(null)
async function registerPasskey() {
registering.value = true
error.value = null
try {
await Passkeys.register({ name: 'My Device' })
// Passkey saved, refresh the list or show a success toast
} catch (e) {
error.value = e.message
} finally {
registering.value = false
}
}
async function loginWithPasskey() {
verifying.value = true
error.value = null
try {
await Passkeys.verify()
// Redirects automatically on success
} catch (e) {
error.value = e.message
} finally {
verifying.value = false
}
}
</script>
<template>
<div class="space-y-4">
<!-- Show on profile/settings for authenticated users -->
<button @click="registerPasskey" :disabled="registering" class="btn">
{{ registering ? 'Registering...' : 'Add a Passkey' }}
</button>
<!-- Show on your login page -->
<button @click="loginWithPasskey" :disabled="verifying" class="btn">
{{ verifying ? 'Verifying...' : 'Sign in with Passkey' }}
</button>
<p v-if="error" class="text-red-500 text-sm">{{ error }}</p>
</div>
</template>
Passkeys.register() handles the full browser ceremony: it fetches the challenge from /passkeys/register/options, prompts the authenticator, and POSTs the resulting credential back to the server. Passkeys.verify() does the same for login and then redirects to the path defined in config/passkeys.php → redirect on success.
For React, the import and API are identical. The Svelte helpers follow the same pattern. The package abstracts all the @simplewebauthn/browser ceremony complexity behind a clean two-method interface, which is what you want when you're not trying to become a WebAuthn expert.
Managing Registered Passkeys
Users should be able to see and revoke their passkeys. This matters more than people expect. Users register on their laptop, their phone, and their work machine, then wonder why three entries show up. Give them the tools to clean it up.
A basic controller looks like this:
// PasskeyController.php
use Illuminate\Http\Request;
use Laravel\Passkeys\Models\Passkey;
class PasskeyController extends Controller
{
public function index(Request $request)
{
$passkeys = $request->user()->passkeys()->latest()->get();
return view('passkeys.index', compact('passkeys'));
}
public function destroy(Request $request, Passkey $passkey)
{
$this->authorize('delete', $passkey);
$passkey->delete();
return back()->with('status', 'Passkey removed.');
}
}
The passkeys() relationship is defined by the PasskeyAuthenticatable trait. Each Passkey record has a name, created_at, and last_used_at column. Surface all three in the UI so users can tell which device is which and spot ones they don't recognise.
Wire the delete action to the DELETE /passkeys/{passkey} route the package already registered. The management_middleware (password confirm by default) protects both the management view and the delete action, so users need to re-authenticate before making changes.
Comparing to spatie/laravel-passkeys
Both packages use web-auth/webauthn-lib under the hood and get you to the same outcome. The difference is approach.
laravel/passkeys (native) is first-party and stack-agnostic on the frontend. Right choice for new Laravel 11, 12, or 13 projects and anything using Fortify. If you're starting fresh, use this.
spatie/laravel-passkeys ships Livewire components out of the box. If your app is already Livewire-heavy and you have Spatie's package working, there's no reason to migrate. The earlier passkeys guide covers that setup in full.
Don't run both at the same time. They register overlapping routes and you'll get conflicts.
Things to Get Right Before You Ship
A few things that will save you a debugging session:
HTTPS is required. WebAuthn only works on secure origins. For local development, use valet secure (Valet or Herd) or configure SSL in Sail. If APP_URL uses http://, the browser refuses to run the ceremony entirely. No error message. Just silence.
Keep password auth as a fallback. Not every user is on a passkey-capable device. Passkeys should be additive. Don't remove your existing login form. Make it an option alongside the passkey button, not a replacement for it.
Account recovery needs thought. If a user loses access to all their registered devices, how do they get back in? The package doesn't solve this. Email-based recovery or admin-initiated password resets are the standard approaches. Build this flow before you go live.
Multiple passkeys per user are supported by default. Users register on multiple devices, and that's expected. Your management UI (a list with a revoke button per passkey) handles this. Show name, created_at, and last_used_at so users can make sense of what's there.
The management_middleware default is password.confirm. Users re-confirm their password before adding or revoking passkeys. Don't strip it out. It's the same security pattern you'd apply to any sensitive account action.
Local Development
One thing that trips people up: APP_URL in .env must match the domain you're actually accessing in the browser. A mismatch makes the relying party check fail, and the error can be cryptic.
APP_URL=https://myapp.test
If you're on Valet:
valet secure myapp
That's all you need. The package reads APP_URL for its relying party config automatically.
FAQ
Does this work on Laravel 11 and 12, or only 13?
The package requires illuminate/contracts: ^11.0|^12.0|^13.0, so all three versions are supported. You don't need to upgrade to Laravel 13 to use it.
Do I need Fortify to use this?
No. Fortify integration is optional. The server package works standalone: you define your own routes and handle redirects. Features::passkeys() just automates the setup if Fortify is already in your stack.
What if I'm already using spatie/laravel-passkeys?
Stay on Spatie unless you have a specific reason to switch, especially if the Livewire setup is working. If you do migrate, uninstall the Spatie package and remove its service provider first. Don't run both simultaneously.
Is v0.1.0 stable enough for production?
The package is already the default in Laravel's official starter kits and backed by Fortify. The v0.1.0 label means the public API may evolve, not that it's experimental. For new projects, use it without hesitation.
Can I use this without a JavaScript framework?
Yes. The framework-specific helpers (Vue, React, Svelte) are convenience wrappers around the same core API. If you're using Blade without a frontend framework, you can call Passkeys.register() and Passkeys.verify() from a plain <script> block after importing @laravel/passkeys.
Get It Wired Up
Native passkeys is a small, focused addition to any Laravel project. The config is sensible by default, Fortify integration is a single line, and the frontend API is two method calls. If you're starting a new Laravel project today and want passwordless auth, this is the path.
If you're adding passkeys to an existing production app or migrating a complex auth setup, get in touch and we can work through the integration together.
Top comments (0)