+1 (415) 599-8902

Passkey Authentication in Laravel 13

Passkeys replace passwords with public-key cryptography: the user's device (Face ID, Touch ID, Windows Hello, or a hardware key) holds a private key, your application stores the matching public key, and signing in is a challenge-response that cannot be phished, reused across sites, or leaked in a database breach. In 2026 they are supported by every major browser and operating system, and Laravel 13's Fortify ships first-party support, wrapping the laravel/passkeys package and exposing a small set of routes plus an official browser client.

This tutorial adds passkeys to an existing Laravel 13 application that already authenticates users with email and password (and possibly TOTP two-factor), without breaking anyone's current login.

WebAuthn in four sentences

WebAuthn is the browser API; a passkey is a WebAuthn credential that syncs across a user's devices. Registration asks the server for creation options (a challenge, your site's identity, the user handle), passes them to navigator.credentials.create(), and posts the resulting public key back. Authentication asks the server for request options, passes them to navigator.credentials.get(), and posts the signed challenge back. The server verifies the signature against the stored public key and that the origin and relying party ID match — which is why those two configuration values matter more than anything else.

Prerequisites

  • Laravel 13, PHP 8.3+, and Fortify installed (composer require laravel/fortify then php artisan fortify:install if you have not already).
  • HTTPS everywhere except localhost. WebAuthn refuses to run on plain HTTP on any other host. Use Herd, Valet's secure, or a local TLS proxy.
  • A frontend that can run JavaScript on the login and profile pages. Blade with a script tag is fine.

Step 1: Enable the feature

In config/fortify.php:

use Laravel\Fortify\Features;

'features' => [
    Features::registration(),
    Features::resetPasswords(),
    Features::twoFactorAuthentication(['confirm' => true]),
    Features::passkeys([
        'confirmPassword' => true,
    ]),
],

'passkeys' => [
    'relying_party_id' => parse_url(config('app.url'), PHP_URL_HOST),
    'allowed_origins' => [config('app.url')],
    'user_handle_secret' => config('app.key'),
    'timeout' => 60000,
],

confirmPassword => true means a user must confirm their password (or an existing passkey) before registering or deleting a passkey, which is the right default for an account-takeover-sensitive operation. relying_party_id must be your application's domain, and allowed_origins must list every origin a browser may use to complete the ceremony. If APP_URL is wrong in production, passkeys will fail with an opaque browser error, so check it first.

fortify:install publishes all of Fortify's migrations, including passkey storage. If you installed Fortify before passkeys existed, re-run the install command (it is safe to re-publish) and then migrate:

php artisan fortify:install
php artisan migrate

Step 2: Prepare the User model

<?php

namespace App\Models;

use Illuminate\Foundation\Auth\User as Authenticatable;
use Illuminate\Notifications\Notifiable;
use Laravel\Fortify\Contracts\PasskeyUser;
use Laravel\Fortify\PasskeyAuthenticatable;
use Laravel\Fortify\TwoFactorAuthenticatable;

class User extends Authenticatable implements PasskeyUser
{
    use Notifiable, PasskeyAuthenticatable, TwoFactorAuthenticatable;
}

That is the entire server-side change for an existing user base. Passwords stay where they are; passkeys are an additional credential type stored against the same user.

Step 3: The routes Fortify gives you

With the feature enabled, Fortify registers:

PurposeMethod and path
Login options (challenge)GET /passkeys/login/options
LoginPOST /passkeys/login
Password-confirmation optionsGET /passkeys/confirm/options
Confirm with passkeyPOST /passkeys/confirm
Registration optionsGET /user/passkeys/options
RegisterPOST /user/passkeys (fields: name, credential)
DeleteDELETE /user/passkeys/{passkey}

Login and registration routes are covered by a dedicated passkeys rate limiter, which you can customise through fortify.limiters.passkeys and a RateLimiter::for(...) definition.

Step 4: The browser side

Install the official client:

npm install @laravel/passkeys

It handles the WebAuthn ceremonies and talks to the routes above. React, Vue, and Svelte helpers are available under @laravel/passkeys/react, /vue, and /svelte; for a Blade application the plain API is enough.

Registering a passkey from the user's security settings page:

import { Passkeys } from '@laravel/passkeys';

document.querySelector('#add-passkey').addEventListener('click', async () => {
    const name = prompt('Name this passkey (e.g. "MacBook Pro")');
    if (!name) return;

    try {
        await Passkeys.register({ name });
        window.location.reload();
    } catch (error) {
        // The user cancelled, or the device has no authenticator.
        console.error(error);
    }
});

Signing in with a passkey on the login page:

import { Passkeys } from '@laravel/passkeys';

document.querySelector('#login-with-passkey').addEventListener('click', async () => {
    try {
        const response = await Passkeys.verify();
        window.location.href = response.redirect ?? '/dashboard';
    } catch (error) {
        document.querySelector('#passkey-error').textContent =
            'Could not sign in with a passkey. Use your password instead.';
    }
});

Passkeys.verify() posts to /passkeys/login; Fortify logs the user in and returns a redirect (or a JSON payload with a redirect key for XHR). If you mount the endpoints under custom paths, pass routes: { options, submit } to either call.

Feature-detect before showing the button. A browser without WebAuthn should see the password form and nothing else:

if (window.PublicKeyCredential) {
    document.querySelector('#login-with-passkey').hidden = false;
}

Step 5: Roll out to an existing user base

Do not force anyone. The rollout that works:

  1. Ship the capability quietly. Add the "Add a passkey" control to the security settings page. Existing password and TOTP logins are untouched.
  2. Prompt after a successful login. Once a user has signed in with their password (and 2FA if enabled), show a one-time, dismissible prompt to add a passkey. That is the moment the device is in their hands and they are already authenticated, so confirmPassword is satisfied.
  3. Offer passkey-first on the login page. Show "Sign in with a passkey" above the password form, with the password form still available.
  4. Let passkeys satisfy second-factor checks. A passkey login is already phishing-resistant multi-factor (device possession plus biometric or PIN). Most teams skip the TOTP challenge when the session was established with a passkey; decide this explicitly and document it.
  5. Keep the fallbacks. Password reset, recovery codes, and a way to remove a lost device's passkey (DELETE /user/passkeys/{id}, behind confirmPassword) all stay. Lost-device support tickets are the main operational cost of passkeys; make removal self-service.
  6. Measure. Track passkey registrations and passkey logins as a share of total logins. When a user has a passkey and stops using their password, you can offer "go passwordless" later — but that is a separate, opt-in step.

Testing

You cannot run a real authenticator in a feature test, but you can test everything around it: that the options endpoints require the right auth state, that registration needs password confirmation, that deleting a passkey is scoped to the owner, and that the login page shows and hides the button correctly. Pest 4 browser tests with Playwright can drive the UI; for the WebAuthn ceremony itself, Playwright's CDP virtual-authenticator support lets you register and sign in with a simulated device in CI, which is worth setting up once if passkeys become your primary login path.

Security notes

  • Never relax allowed_origins to a wildcard. Origin binding is the property that makes passkeys unphishable.
  • user_handle_secret derives opaque user identifiers. Changing APP_KEY without migrating this value will orphan every registered passkey; if you ever rotate APP_KEY, pin user_handle_secret to its own value first.
  • Log passkey registrations and deletions and notify the user by email when one happens, the same way you would for a password change.

Passkeys are the rare security feature that makes login easier for users. If you want help adding them to a production Laravel application, or upgrading to a Laravel version that supports them, contact us or see our Laravel upgrade services.