Laravel 13 Passkeys: A Deep Dive into WebAuthn
Passwords are the weakest link in most SaaS apps. Users reuse them, forget them, and paste them into phishing pages. Passkeys fix that by replacing shared secrets with public-key cryptography tied to the user’s device.
Laravel 13 (released March 17, 2026, PHP 8.3+) is the smoothest place to adopt passkeys. First-party support comes from Fortify wrapping laravel/passkeys (compatible with Laravel 11–13), official starter kits that scaffold the UI, and @laravel/passkeys for browser WebAuthn ceremonies.
This guide builds passkey auth into a realistic Laravel 13 SaaS — NoteVault, a multi-device note-taking product — from empty project through registration, login, multi-device management, recovery, testing, and production hardening. You get working code you can ship, plus enough protocol knowledge to debug when something breaks.
Who this is for: Laravel developers targeting Laravel 13 who want production-ready passkeys without stitching together abandoned community packages.
Quick answer: Laravel 13 supports passkeys natively — enable Features::passkeys() in config/fortify.php, install the @laravel/passkeys npm package for the browser side, and Fortify handles the WebAuthn registration and login endpoints for you. You still own the RP ID/origin configuration, the UI, and recovery strategy, which is what the rest of this guide walks through.
What you’ll build:
- Email + password signup (baseline fallback)
- Add a passkey after login (iPhone, Mac, Windows, Android, security key)
- Sign in with a passkey
- Confirm sensitive actions with a passkey (password confirmation replacement)
- Manage multiple passkeys per account
- Recover when a device is lost
Table of contents
- What WebAuthn and Passkeys are
- Why Passkeys instead of passwords
- How WebAuthn works behind the scenes
- Laravel 13 project setup
- Packages and configuration
- Database schema
- User model and Fortify wiring
- Registration flow (create credential)
- Authentication flow (login with passkey)
- Frontend with @laravel/passkeys
- Challenge generation and verification
- How credentials are stored
- Multiple devices and multiple passkeys
- Account recovery strategies
- Security best practices
- Common mistakes and debugging
- Testing across browsers and platforms
- Production deployment
- Customization and advanced hooks
- When to go deeper (protocol DIY)
1. What WebAuthn and Passkeys are
WebAuthn (Web Authentication) is a W3C browser API. Your site asks the browser to create or use a cryptographic credential. The private key never leaves the authenticator (phone Secure Enclave, TPM, YubiKey, etc.). Your server only ever stores a public key.
Passkeys are the product name for discoverable WebAuthn credentials that can sync across a user’s devices via iCloud Keychain, Google Password Manager, or similar. From your app’s point of view, a passkey is still a WebAuthn credential — the sync layer is handled by the OS/vendor.
Think of it like a house key:
| Concept | House analogy | Passkey reality |
|---|---|---|
| Private key | Physical key you keep | Stays in the phone/laptop/security key |
| Public key | Lock on your door | Stored in your Laravel database |
| Challenge | Random one-time door code | Random bytes your server generates per login |
| Biometric prompt | Looking through the peephole | Face ID / Touch ID / Windows Hello / PIN |
Important: Face ID does not send your face to NoteVault. It unlocks the private key locally. Your server only verifies a signature.
Two ceremonies matter:
- Registration (attestation) — create a new credential and store the public key.
- Authentication (assertion) — prove possession of the private key by signing a challenge.
In Laravel 13, Fortify exposes HTTP endpoints for both ceremonies. You rarely call web-auth/webauthn-lib yourself — but you still need to understand the flow to configure RP ID, origins, and recovery correctly.
2. Why Passkeys instead of passwords
For a SaaS like NoteVault, passkeys win on both UX and security:
| Problem with passwords | How passkeys help |
|---|---|
| Phishing (fake login page) | Credential is bound to your domain (notevaulttai.in). It will not unlock on notevault-hq.com. |
| Credential stuffing | There is no shared secret to steal from another breach. |
| Weak / reused passwords | No password to reuse. |
| SMS OTP interception | Passkeys can replace (or strengthen) second factor. |
| “Forgot password” support load | Fewer resets; recovery becomes an explicit, deliberate path. |
You do not have to go passwordless on day one. A practical Laravel 13 rollout looks like:
- Keep email/password (Fortify registration + reset).
- Enable
Features::passkeys(). - Let users add passkeys in Settings.
- Offer “Sign in with passkey” on the login page.
- Later, make passkeys the default and passwords the fallback.
That gradual path is what we implement below.
3. How WebAuthn works behind the scenes
Registration (create a passkey)
Sequence diagram showing a user registering a passkey — browser requests creation options from Laravel Fortify, the authenticator prompts for Face ID or PIN, and the signed credential is posted back to the server for storage.
Authentication (sign in)
Sequence diagram showing passkey login — browser fetches a login challenge from Laravel Fortify, the authenticator verifies the user biometrically, and the signed assertion is posted back to create a session.
Roles in the protocol
- Relying Party (RP) — your Laravel app. Identified by RP ID (usually the domain host, e.g.
notevaulthq.com). - Authenticator — platform (Touch ID) or roaming (YubiKey / phone via QR).
- Client — browser that mediates between RP and authenticator.
- User handle — opaque user id derived by Laravel Passkeys (
user_handle_secret) so discoverable credentials can identify the account without typing an email.
4. Laravel 13 project setup
Requirements
| Piece | Version |
|---|---|
| Laravel | 13.x |
| PHP | 8.3 – 8.5 |
| Auth backend | Fortify (starter kits use it by default) |
| Passkeys server | laravel/passkeys (pulled in via Fortify feature) |
| Passkeys JS | @laravel/passkeys |
Path A — New app with a starter kit (fastest)
Laravel 13 starter kits (React, Vue, Livewire) use Fortify for auth and include passkey scaffolding when you enable the feature during / after install.
laravel new notevault # Choose the React, Vue, or Livewire starter kit when prompted. # Passkeys are wired through Fortify + @laravel/passkeys in the kit. cd notevault
Confirm Features::passkeys() is present in config/fortify.php (next section). Starter kits ship Security settings pages you can extend for multi-device management.
Path B — Existing Laravel 13 app (or custom Blade frontend)
If NoteVault already has Fortify (or you install it yourself):
composer require laravel/fortify php artisan vendor:publish --provider="Laravel\Fortify\FortifyServiceProvider" # Publishes Fortify config/actions and the passkeys migration (via fortify-migrations) php artisan vendor:publish --tag=fortify-migrations php artisan migrate
Then enable the passkeys feature and update the User model as shown below. Fortify depends on laravel/passkeys, calls Passkeys::ignoreRoutes() so the package does not double-register routes, and registers the Fortify passkey endpoints when the feature is on.
HTTPS from day one
WebAuthn requires a secure context (HTTPS, or localhost).
APP_NAME=NoteVault APP_URL=https://notevault.test SESSION_DOMAIN=notevault.test SESSION_SECURE_COOKIE=true
php Laravel Herd or Valet, notevault.test already has HTTPS. With php artisan serve, stick to http://localhost:8000 — browsers treat localhost as secure. Avoid 127.0.0.1 for passkey testing; use localhost.
5. Packages and configuration
Enable Fortify passkeys
In config/fortify.php:
use Laravel\Fortify\Features;
'features' => [
Features::registration(),
Features::resetPasswords(),
Features::emailVerification(),
Features::updateProfileInformation(),
Features::updatePasswords(),
Features::twoFactorAuthentication([
'confirm' => true,
'confirmPassword' => true,
]),
// First-party passkeys (Fortify + laravel/passkeys)
Features::passkeys([
// Require password (or prior confirmation) before add/delete passkey
'confirmPassword' => true,
]),
],
Also ensure the rate limiter key is listed (starter kits already do this):
'limiters' => [
'login' => 'login',
'two-factor' => 'two-factor',
'passkeys' => 'passkeys',
],
Passkeys block in the same file
Fortify overrides config/passkeys.php when the feature is enabled. Configure passkeys here, not in a published passkeys config:
// config/fortify.php
'passkeys' => [
// Host only — no scheme, no path, no port
'relying_party_id' => parse_url((string) config('app.url'), PHP_URL_HOST),
// Exact browser origins allowed to complete ceremonies
'allowed_origins' => [
config('app.url'),
// env('WEBAUTHN_EXTRA_ORIGIN'), // e.g. https://app.notevaulthq.com during cutover
],
// Derives stable opaque WebAuthn user handles
'user_handle_secret' => env('PASSKEYS_USER_HANDLE_SECRET', config('app.key')),
// Browser timeout hint (ms)
'timeout' => 60_000,
],
.env:
PASSKEYS_USER_HANDLE_SECRET= # leave empty to use APP_KEY; set a dedicated secret in production if you rotate APP_KEY often
Why these matter: if relying_party_id or allowed_origins does not match what the browser sees, verification fails with opaque client errors. Misconfigured RP ID is the #1 production bug.
Package maturity note: laravel/passkeys is still pre-1.0 as of this writing. Pin an exact version range in composer.json rather than ^1.0, and re-check the changelog before upgrading — the public API can still shift between minor releases.
Without Fortify (package alone)
If you are not using Fortify, you can still use the package directly:
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;
}
For NoteVault we stay on the Fortify path — that is the Laravel 13-supported integration.
JavaScript client
npm install @laravel/passkeys
6. Database schema
When passkeys are enabled, Fortify / laravel/passkeys provides a migration that creates a passkeys table shaped like this:
Schema::create('passkeys', function (Blueprint $table) {
$table->id();
$table->foreignId('user_id')->constrained()->cascadeOnDelete();
$table->string('name'); // "MacBook Pro", "iPhone 15"
$table->string('credential_id')->unique();
$table->json('credential'); // public key material + metadata
$table->timestamp('last_used_at')->nullable();
$table->timestamps();
$table->index('user_id');
});
What lives in credential (JSON): the WebAuthn credential record the package needs to verify future assertions — public key, counter, AAGUID, transports, backup flags, and related fields. You store public material only. The private key never reaches Laravel.
Optional recovery support on users (not shipped by the package — add it yourself):
php artisan make:migration add_recovery_codes_to_users_table
Schema::table('users', function (Blueprint $table) {
$table->text('recovery_codes')->nullable()->after('password');
});
Run migrations:
php artisan migrate
7. User model and Fortify wiring
With Fortify passkeys enabled, the User model must implement Fortify’s contract and trait:
<?php
namespace App\Models;
use Illuminate\Database\Eloquent\Factories\HasFactory;
use Illuminate\Foundation\Auth\User as Authenticatable;
use Illuminate\Notifications\Notifiable;
use Laravel\Fortify\Contracts\PasskeyUser;
use Laravel\Fortify\PasskeyAuthenticatable;
class User extends Authenticatable implements PasskeyUser
{
/** @use HasFactory \Database\Factories\UserFactory> */
use HasFactory, Notifiable, PasskeyAuthenticatable;
protected $fillable = [
'name',
'email',
'password',
];
protected $hidden = [
'password',
'remember_token',
'recovery_codes',
];
protected function casts(): array
{
return [
'email_verified_at' => 'datetime',
'password' => 'hashed',
'recovery_codes' => 'array',
];
}
}
The PasskeyAuthenticatable trait wires the passkeys() relationship and the display name / username values authenticators show in their UI. Override if needed:
public function getPasskeyDisplayName(): string
{
return $this->name;
}
public function getPasskeyUsername(): string
{
return $this->email;
}
Routes Fortify registers for you
| Method | URI | Purpose |
|---|---|---|
GET |
/passkeys/login/options |
Login challenge options |
POST |
/passkeys/login |
Verify assertion + create session |
GET |
/passkeys/confirm/options |
Step-up / password-confirm challenge |
POST |
/passkeys/confirm |
Mark session as confirmed |
GET |
/user/passkeys/options |
Registration challenge options |
POST |
/user/passkeys |
Store new passkey |
DELETE |
/user/passkeys/{passkey} |
Remove a passkey |
You do not write these controllers for the happy path. You build UI that calls them (directly or via @laravel/passkeys).
Fortify calls Passkeys::ignoreRoutes() so the package does not register its own copies of these URIs, then registers the endpoints above when Features::passkeys() is enabled. That avoids double routes.
List registered routes anytime:
php artisan route:list --path=passkey
8. Registration flow (create credential)
Registration is a two-step dance. Fortify already implements both steps.
- Authenticated user requests options →
GET /user/passkeys/options - Browser runs
navigator.credentials.create(...) - Frontend POSTs
{ name, credential }→POST /user/passkeys
With the official JS client this collapses to:
import { Passkeys } from '@laravel/passkeys'
await Passkeys.register({ name: 'MacBook Pro' })
Under the hood that helper:
- Fetches creation options from Fortify
- Calls the WebAuthn API
- Posts the credential back
- Surfaces errors in a consistent shape
What the HTTP calls look like
You normally will not write this by hand — @laravel/passkeys does it — but knowing the shape helps when debugging network tabs:
GET /user/passkeys/options→ JSON wrapped as{ "options": { /* PublicKeyCredentialCreationOptions */ } }(the npm client unwrapsoptionsfor you)- Browser:
navigator.credentials.create({ publicKey })after base64url →ArrayBufferconversion POST /user/passkeyswith body:
{
"name": "MacBook Pro",
"credential": { "id": "...", "rawId": "...", "type": "public-key", "response": { "...": "..." } }
}
Login and confirm option endpoints use the same { "options": ... } envelope.
Prefer Passkeys.register({ name }) in production NoteVault code — the base64url conversion edge cases are easy to get wrong by hand.
Settings page (Blade)
{{-- resources/views/settings/passkeys.blade.php --}}
<x-app-layout>
<x-slot name="header">
<h2 class="font-semibold text-xl text-gray-800">Passkeys</h2>
</x-slot>
<div class="py-8 max-w-3xl mx-auto sm:px-6 lg:px-8 space-y-6">
@if (session('status') === 'passkey-registered')
<div class="text-sm text-green-700">Passkey added.</div>
@endif
@if (session('status') === 'passkey-deleted')
<div class="text-sm text-green-700">Passkey removed.</div>
@endif
<div class="bg-white shadow rounded-lg p-6 space-y-4">
<p class="text-sm text-gray-600">
Passkeys let you sign in with Face ID, Touch ID, Windows Hello, or a security key.
Add one per device you use often.
</p>
<div class="flex gap-2">
<input id="passkey-name" type="text" class="border rounded px-3 py-2 w-full"
placeholder="e.g. MacBook Pro" value="MacBook Pro">
<button id="add-passkey" type="button"
class="bg-indigo-600 text-white px-4 py-2 rounded">
Add passkey
</button>
</div>
<p id="passkey-error" class="text-sm text-red-600 hidden"></p>
</div>
<div class="bg-white shadow rounded-lg divide-y">
@forelse (auth()->user()->passkeys as $passkey)
<div class="p-4 flex items-center justify-between gap-4">
<div>
<div class="font-medium">{{ $passkey->name }}</div>
<div class="text-xs text-gray-500">
Added {{ $passkey->created_at->toFormattedDateString() }}
@if ($passkey->last_used_at)
· Last used {{ $passkey->last_used_at->diffForHumans() }}
@endif
@if ($passkey->authenticator)
· {{ $passkey->authenticator }}
@endif
</div>
</div>
<form method="POST" action="/user/passkeys/{{ $passkey->id }}"
onsubmit="return confirm('Remove this passkey?')">
@csrf
@method('DELETE')
<button class="text-sm text-red-600">Remove</button>
</form>
</div>
@empty
<div class="p-4 text-sm text-gray-500">No passkeys yet.</div>
@endforelse
</div>
</div>
</x-app-layout>
Route for the settings page (your app owns this view; Fortify owns the ceremony endpoints):
Route::middleware(['auth', 'password.confirm'])->group(function () {
Route::view('/settings/passkeys', 'settings.passkeys')->name('passkeys.index');
});
password.confirm matches Fortify’s confirmPassword => true option so users re-auth before managing credentials.
9. Authentication flow (login with passkey)
One-liner with the official client
import { Passkeys } from '@laravel/passkeys'
await Passkeys.verify()
// On success Fortify logs the user in and returns a redirect (or JSON redirect for XHR)
Login page button
<button type="button" id="passkey-login"
class="w-full border rounded py-2 mt-3">
Sign in with passkey
</button>
<script type="module">
import { Passkeys } from '@laravel/passkeys'
document.getElementById('passkey-login')?.addEventListener('click', async () => {
try {
await Passkeys.verify()
// Package / Fortify will follow the redirect for full page flows.
// For XHR, read the JSON redirect and navigate:
// window.location = data.redirect
} catch (e) {
alert(e.message || 'Passkey sign-in failed')
}
})
</script>
What the server does
GET /passkeys/login/options— issues a challenge (discoverable / usernameless when no allow-list is needed).- Browser assertion via
navigator.credentials.get(...). POST /passkeys/loginwith the credential (optionalremember: true).- Fortify verifies the signature against the stored public key, updates
last_used_at, logs the user into the configured guard, regenerates the session.
Step-up confirmation (replace “confirm password”)
For billing changes, exports, or deleting the account:
await Passkeys.verify({
routes: {
options: '/passkeys/confirm/options',
submit: '/passkeys/confirm',
},
})
That satisfies Laravel’s password-confirmation session flag without typing a password — ideal once users are passkey-first.
10. Frontend with @laravel/passkeys
Install and import
npm install @laravel/passkeys
// resources/js/app.js
import { Passkeys } from '@laravel/passkeys'
window.NoteVaultPasskeys = {
async register(name) {
return Passkeys.register({ name })
},
async login() {
return Passkeys.verify()
},
async confirm() {
return Passkeys.verify({
routes: {
options: '/passkeys/confirm/options',
submit: '/passkeys/confirm',
},
})
},
}
Wire the settings button:
document.getElementById('add-passkey')?.addEventListener('click', async () => {
const name = document.getElementById('passkey-name').value.trim() || 'My device'
const errorEl = document.getElementById('passkey-error')
errorEl.classList.add('hidden')
try {
await window.NoteVaultPasskeys.register(name)
window.location.reload()
} catch (e) {
errorEl.textContent = e.message || 'Could not add passkey'
errorEl.classList.remove('hidden')
}
})
Vue / React / Svelte helpers
Starter-kit apps should use the official framework hooks — usePasskeyVerify and usePasskeyRegister (not a single usePasskeys hook).
Vue:
<script setup>
import { usePasskeyVerify, usePasskeyRegister } from '@laravel/passkeys/vue'
const { verify, isLoading, error, isSupported } = usePasskeyVerify({
autofill: true,
onSuccess: (response) => {
if (response.redirect) {
window.location.href = response.redirect
}
},
})
const { register, isLoading: isRegistering, error: registerError } = usePasskeyRegister()
// register('MacBook Pro')
</script>
<template>
<div>
<!-- webauthn token anchors the autofill dropdown -->
<input type="text" autocomplete="email webauthn" />
<button @click="verify" :disabled="!isSupported || isLoading">
{{ isLoading ? 'Authenticating...' : 'Sign in with passkey' }}
</button>
<p v-if="error">{{ error }}</p>
</div>
</template>
React and Svelte use the same hook names from @laravel/passkeys/react and @laravel/passkeys/svelte. On Svelte, do not destructure the hook return value — keep the object so reactivity stays live.
These helpers are SSR-safe: isSupported is false on the server, then updates after mount. With autofill: true, include webauthn in an input’s autocomplete attribute or the browser has nowhere to attach the picker.
Custom endpoint overrides
await Passkeys.register({
name: 'MacBook Pro',
routes: {
options: '/user/passkeys/options',
submit: '/user/passkeys',
},
})
Rebuild assets:
npm run build # or npm run dev
11. Challenge generation and verification
You do not generate challenges by hand with Fortify — but you must understand the rules so you do not break them.
| Rule | Why |
|---|---|
| Challenge is random, high entropy | Prevents replay of old assertions |
| Options are bound to the ceremony | Server must verify against the same challenge it issued |
| Single-use | Fortify consumes the challenge after verify |
| Short-lived | Reduces the replay window |
| Origin + RP ID checked | Stops phishing on lookalike domains |
Laravel Passkeys uses web-auth/webauthn-lib under the hood for cryptographic verification. Fortify applies a dedicated passkeys rate limiter to login, confirmation, and registration routes.
Wire the limiter name in config/fortify.php:
'limiters' => [
'login' => 'login',
'two-factor' => 'two-factor',
'passkeys' => 'passkeys',
],
Then define it in App\Providers\FortifyServiceProvider (or AppServiceProvider):
use Illuminate\Cache\RateLimiting\Limit;
use Illuminate\Support\Facades\RateLimiter;
use Illuminate\Http\Request;
RateLimiter::for('passkeys', function (Request $request) {
return Limit::perMinute(5)->by($request->session()->getId() ?: $request->ip());
});
Never implement signature verification yourself unless you are writing a crypto library.
12. How credentials are stored
The package’s Passkey model stores:
| Column | Purpose |
|---|---|
name |
Human label in Settings |
credential_id |
Unique id from the authenticator |
credential |
JSON credential record (public key + metadata) |
last_used_at |
Support and UX (“last used 2 hours ago”) |
authenticator (accessor) |
Friendly label from AAGUID when known |
Checklist:
| Store | Do not store |
|---|---|
| Credential ID + public key material | Private keys (you never receive them) |
Counter / backup flags inside credential |
Biometric templates |
| Last used timestamp | Raw recovery codes (hash them) |
Practical tips:
- Treat the
passkeystable like auth data — least-privilege DB access. - Do not log full assertion payloads in production.
- Prefer the package’s model; extend it if you need soft deletes or tenant scoping:
use Laravel\Passkeys\Passkey as BasePasskey;
use Laravel\Passkeys\Passkeys;
class Passkey extends BasePasskey
{
protected static function booted(): void
{
static::created(function (Passkey $passkey) {
// e.g. notify security channel
});
}
}
// AppServiceProvider::boot()
Passkeys::usePasskeyModel(\App\Models\Passkey::class);
Passkeys::useUserModel(\App\Models\User::class);
13. Multiple devices and multiple passkeys
Real SaaS users own a laptop, a phone, and sometimes a YubiKey. The Laravel 13 schema already allows many rows per user_id.
Product rules that work
- Allow many passkeys per user (no artificial “one device” limit).
- Show name, last used, and authenticator label.
- Encourage a second passkey before removing the first.
- On a new device after password login, prompt: “Add a passkey on this device?”
- Block deleting the last passkey when the user has no password (custom policy — see recovery).
Nudge after login
// Dashboard controller / Inertia prop / Livewire computed
$needsPasskey = auth()->user()->passkeys()->count() === 0;
@if ($needsPasskey)
<div class="border p-4 rounded mb-4">
Protect your account —
<a href="{{ route('passkeys.index') }}" class="underline">add a passkey</a>.
</div>
@endif
Cross-device (hybrid) login
When the browser offers a QR code to use a phone as authenticator, that is the hybrid transport. You do not special-case it in Laravel — platform authenticators and security keys both flow through the same Fortify endpoints.
14. Account recovery strategies
Passkeys fail closed: lose every authenticator and you are locked out unless you planned recovery.
Pick at least two of these for NoteVault:
A. Password still exists (simplest hybrid)
Keep Fortify password reset via email. Passkeys become the preferred login; passwords remain the escape hatch. Many B2B apps stay here forever — and that is fine.
B. One-time recovery codes
Generate when the user adds their first passkey:
use Illuminate\Support\Facades\Hash;
use Illuminate\Support\Str;
public function generateRecoveryCodes(User $user): array
{
$plain = collect(range(1, 8))
->map(fn () => Str::upper(Str::random(4).'-'.Str::random(4)))
->all();
$user->forceFill([
'recovery_codes' => collect($plain)->map(fn ($c) => Hash::make($c))->values()->all(),
])->save();
return $plain; // show once — force download/print
}
public function consumeRecoveryCode(User $user, string $code): bool
{
$hashes = $user->recovery_codes ?? [];
foreach ($hashes as $i => $hash) {
if (Hash::check($code, $hash)) {
unset($hashes[$i]);
$user->forceFill(['recovery_codes' => array_values($hashes)])->save();
return true;
}
}
return false;
}
C. Email magic-link re-enrollment
If the mailbox is still trusted:
- “Lost access” → email a signed, single-use link.
- Link creates a short-lived session that can only open passkey settings.
- User must register a new passkey before accessing the app.
D. Admin / IT recovery (B2B)
Allow an org admin to reset login methods after identity proofing. Log every recovery as a security event.
Guard last-factor deletion
Fortify’s delete endpoint removes a passkey. Customize DeletePasskey so users cannot strand themselves with no login method:
<?php
namespace App\Actions\Passkeys;
use Illuminate\Contracts\Auth\Authenticatable;
use Illuminate\Validation\ValidationException;
use Laravel\Passkeys\Actions\DeletePasskey;
use Laravel\Passkeys\Contracts\PasskeyUser;
use Laravel\Passkeys\Passkey;
class SafeDeletePasskey extends DeletePasskey
{
public function __invoke(Authenticatable $user, Passkey $passkey): void
{
if (! $user instanceof PasskeyUser) {
throw new \RuntimeException('User model must implement PasskeyUser.');
}
$remaining = $user->passkeys()->whereKeyNot($passkey->getKey())->count();
$hasPassword = filled($user->getAuthPassword());
if ($remaining === 0 && ! $hasPassword) {
throw ValidationException::withMessages([
'passkey' => ['Add another passkey or a password before removing your last passkey.'],
]);
}
parent::__invoke($user, $passkey);
}
}
Bind it in a service provider:
use App\Actions\Passkeys\SafeDeletePasskey; use Laravel\Passkeys\Actions\DeletePasskey; $this->app->bind(DeletePasskey::class, SafeDeletePasskey::class);
What not to do
- Do not SMS-reset high-value accounts without extra checks (SIM swap).
- Do not allow recovery that only asks for the account email and a CAPTCHA.
- Do not delete all passkeys without another factor already verified in-session.
15. Security best practices
- HTTPS everywhere with the correct
relying_party_idandallowed_origins. - Keep
confirmPassword => truefor registration/deletion in production. - Use passkey confirmation for sensitive actions (
/passkeys/confirm). - Leave Fortify’s passkeys rate limiter enabled; tighten if you see abuse.
- Regenerate sessions on login (Fortify does this — do not bypass with a custom login response that skips it).
- Authorize passkey delete by ownership (package does; keep it if you override routes).
- Prevent last-factor removal unless another factor exists.
- Set a dedicated
PASSKEYS_USER_HANDLE_SECRETif you rotateAPP_KEYoften — user handles must remain stable. - Monitor
PasskeyRegistered,PasskeyVerified, andPasskeyDeletedevents. - CSP: allow your own scripts; avoid unnecessary
unsafe-inline. - SameSite cookies (
lax/strict) on the session cookie. - Prefer official packages (
laravel/passkeys+@laravel/passkeys) over abandoned community forks.
Listen for security-relevant events:
use Illuminate\Support\Facades\Event;
use Laravel\Passkeys\Events\PasskeyRegistered;
use Laravel\Passkeys\Events\PasskeyVerified;
use Laravel\Passkeys\Events\PasskeyDeleted;
Event::listen(PasskeyRegistered::class, function ($event) {
logger()->info('passkey.registered', [
'user_id' => $event->passkey->user_id,
'passkey_id' => $event->passkey->id,
]);
});
16. Common mistakes and debugging
Mistake: RP ID / origin mismatch
Symptom: NotAllowedError in the browser, or server-side invalid origin.
Fix: relying_party_id must be the host. allowed_origins must match APP_URL exactly (scheme + host + port).
| URL in browser | Good RP ID | Bad RP ID |
|---|---|---|
https://notevault.test |
notevault.test |
localhost |
https://app.notevaulthq.com |
notevaulthq.com or app.notevaulthq.com |
https://app.notevaulthq.com |
http://127.0.0.1:8000 |
often fails | use http://localhost:8000 |
Mistake: configuring config/passkeys.php while Fortify is enabled
Symptom: your published config appears ignored.
Fix: with Features::passkeys(), configure the passkeys array in config/fortify.php. Fortify overrides the package config.
Mistake: forgetting CSRF on JSON POSTs
Symptom: 419 Page Expired.
Fix: @laravel/passkeys handles this when cookies/CSRF meta are present. For DIY fetch, send X-CSRF-TOKEN.
Mistake: testing on HTTP non-localhost
Symptom: PublicKeyCredential unavailable or immediate failure.
Fix: HTTPS or localhost only.
Mistake: wrong User contract
Symptom: type errors or missing relationship.
Fix: with Fortify use Laravel\Fortify\Contracts\PasskeyUser + PasskeyAuthenticatable. With the package alone use Laravel\Passkeys\... equivalents.
Debugging toolkit
PublicKeyCredential.isUserVerifyingPlatformAuthenticatorAvailable() .then(console.log)
Chrome DevTools → WebAuthn / virtual authenticators lets you test without a phone.
php artisan route:list --path=passkey tail -f storage/logs/laravel.log
In local only, surface validator messages; in production keep errors generic.
17. Testing across browsers and platforms
| Platform | Authenticator | Notes |
|---|---|---|
| Chrome (macOS/Windows) | Platform + profile passkeys | Best virtual authenticator support |
| Safari (macOS/iOS) | iCloud Keychain | Requires iCloud Keychain on |
| Firefox | Platform / security keys | Sync lags Chromium/Safari; local credentials work |
| Android Chrome | Google Password Manager | Hybrid QR works well desktop↔phone |
| iPhone Safari | Face ID + iCloud | Watch embedded WebViews (SFSafariViewController) |
| Windows | Windows Hello | Needs Hello enrolled |
| macOS | Touch ID | Safari/Chrome prompts differ slightly |
Manual ship checklist
- Register on Chrome desktop → login on same browser.
- Register on iPhone Safari → usernameless login on same phone.
- Synced passkey: iPhone → Mac Safari (same Apple ID).
- Security key (USB/NFC) register + login.
- Hybrid: desktop options → QR with phone.
- Delete passkey → cannot login with it.
confirmPasswordgate on add/delete.- Step-up confirm via
/passkeys/confirm. - Expired / cleared session mid-ceremony → friendly error.
- Wrong origin → must fail.
18. Production deployment
Domains and cookies
APP_URL=https://app.notevaulthq.com SESSION_DOMAIN=.notevaulthq.com SESSION_SECURE_COOKIE=true
// config/fortify.php
'passkeys' => [
'relying_party_id' => 'notevaulthq.com',
'allowed_origins' => [
'https://app.notevaulthq.com',
],
'user_handle_secret' => env('PASSKEYS_USER_HANDLE_SECRET', env('APP_KEY')),
'timeout' => 60_000,
],
If the SPA and API are on different sites, complete WebAuthn on the origin that owns the session cookies (Sanctum SPA pattern), or keep them same-site.
Apple / well-known passkey endpoints
Laravel starter kits can expose /.well-known/passkey-endpoints when passkeys are enabled at install time. That well-known URL helps Apple devices discover your relying party (see the Laravel 13.x changelog). If you are not on a starter kit, copy the kit’s well-known route or add an equivalent endpoint for your deployment.
Feature flag the rollout
// Simple env / config flag — no Pennant required
if (config('features.passkeys')) {
// show passkey UI
}
19. Customization and advanced hooks
Block banned users after a valid assertion
use Illuminate\Http\Request;
use Illuminate\Validation\ValidationException;
use Laravel\Passkeys\Contracts\PasskeyUser;
use Laravel\Passkeys\Passkey;
use Laravel\Passkeys\Passkeys;
Passkeys::authorizeLoginUsing(function (Request $request, PasskeyUser $user, Passkey $passkey): bool {
if (method_exists($user, 'isBanned') && $user->isBanned()) {
throw ValidationException::withMessages([
'credential' => ['This account has been suspended.'],
]);
}
return true;
});
Custom registration options (platform-only)
use Laravel\Passkeys\Actions\GenerateRegistrationOptions;
use Webauthn\AuthenticatorSelectionCriteria;
class PlatformOnlyRegistrationOptions extends GenerateRegistrationOptions
{
public function authenticatorSelection(): AuthenticatorSelectionCriteria
{
return AuthenticatorSelectionCriteria::create(
authenticatorAttachment: AuthenticatorSelectionCriteria::AUTHENTICATOR_ATTACHMENT_PLATFORM,
userVerification: AuthenticatorSelectionCriteria::USER_VERIFICATION_REQUIREMENT_REQUIRED,
residentKey: AuthenticatorSelectionCriteria::RESIDENT_KEY_REQUIREMENT_REQUIRED,
);
}
}
// AppServiceProvider::register()
$this->app->bind(GenerateRegistrationOptions::class, PlatformOnlyRegistrationOptions::class);
Available actions: GenerateRegistrationOptions, GenerateVerificationOptions, StorePasskey, VerifyPasskey, DeletePasskey.
Custom login JSON response
use Laravel\Passkeys\Contracts\PasskeyLoginResponse;
class NoteVaultLoginResponse implements PasskeyLoginResponse
{
public function toResponse($request)
{
return response()->json([
'redirect' => route('dashboard'),
]);
}
}
$this->app->singleton(PasskeyLoginResponse::class, NoteVaultLoginResponse::class);
Disable package routes (own routing)
use Laravel\Passkeys\Passkeys; Passkeys::ignoreRoutes();
Only do this if you know you will re-register equivalent secure endpoints.
20. When to go deeper (protocol DIY)
For Laravel 13 NoteVault, prefer Fortify + laravel/passkeys + @laravel/passkeys. That stack is maintained, documented, and aligned with starter kits.
Build on raw web-auth/webauthn-lib only when you need something the first-party layer cannot express yet (exotic multi-tenant RP IDs per request, non-HTTP authenticators, research prototypes). If you go that route, you are re-implementing challenge storage, serializers, attestation/assertion validators, and counters — the same engine Laravel Passkeys already wraps.
Community packages such as Laragear WebAuthn are superseded by
laravel/passkeys. Do not start new Laravel 13 work on abandoned forks.
Key takeaways
- Laravel 13 supports passkeys natively through Fortify’s
Features::passkeys(), thelaravel/passkeysserver package, and the@laravel/passkeysnpm client — no third-party WebAuthn library required. - Your server only ever stores a public key; the private key stays on the user’s device (Secure Enclave, TPM, or hardware security key), so a database breach cannot leak credentials that unlock accounts.
- The most common production failure is a mismatch between
relying_party_id/allowed_originsinconfig/fortify.phpand the actual browser origin — verify these before debugging anything else. - Passkeys fail closed: plan recovery (password fallback, one-time codes, or admin reset) before rollout, and block deletion of a user’s last authentication factor.
laravel/passkeysis pre-1.0 — pin your version and review the changelog before upgrading in production.
Frequently asked questions
Does Face ID or Touch ID send biometric data to the Laravel server?
No. Biometrics unlock the private key locally on the device. NoteVault’s server only ever receives and verifies a cryptographic signature, never the biometric itself.
Do passkeys replace passwords entirely in Laravel 13?
Not by default. Fortify lets you run passkeys alongside email/password login, so you can roll out passkeys as an optional, then preferred, sign-in method before ever removing passwords.
Why does passkey verification fail with a NotAllowedError?
This is almost always an RP ID or origin mismatch — relying_party_id in config/fortify.php must be the bare host, and allowed_origins must match the browser’s origin exactly (scheme, host, and port).
Can a user lose access to NoteVault if they lose their device?
Yes, unless you’ve configured recovery. Section 14 covers password fallback, one-time recovery codes, email re-enrollment, and admin recovery — pick at least two before shipping passkeys to real users.
Do passkeys work the same across Chrome, Safari, and Firefox?
The WebAuthn ceremonies are standardized, but sync behavior differs — see the platform table in Section 17. Test registration and login on real Chrome, Safari, and Firefox installs, not just one browser, before launch.
Further reading
- Laravel 13 Fortify — Passkeys
- Laravel 13 starter kits
- laravel/passkeys (server)
- @laravel/passkeys (JS)
- W3C WebAuthn
- passkeys.dev — stakeholder-friendly diagrams
- MDN: Web Authentication API
For more insightful tutorials, visit our Tech Blogs and explore the latest in Laravel, AI, and Vue.js development

