Skip to content
Bladewell
Guides

Passkey sign-in for Laravel with Fortify

Fortify's passkeys as Blade screens: passkeys offered in the email field, a confirm step that takes one, and a page to add and remove them.

php artisan bladewell:add auth-layout text-input password checkbox button toast

Uses Auth layout, Text input, Password, Checkbox, Button and Toast. New to Bladewell? Install it first.

What Fortify does, and what you build

A passkey signs people in with their fingerprint, face or screen lock instead of a password. Nothing secret is sent or stored on your server, and it only works on your own domain, so a lookalike site can't trick anyone into handing it over. Laravel Fortify does the WebAuthn side: it makes the challenges, checks the answers, stores each passkey and signs the person in. It ships no screens, and no browser code beyond a small client.

This guide builds the three screens passkeys need: a sign-in page where the browser offers saved passkeys as people type their email, a "confirm it's you" step that takes a passkey or the password, and a settings page to add, list and remove passkeys. The pages are Bladewell's auth layout and form fields, with one short script.

It was checked against Fortify 1.41 (with laravel/passkeys 0.2) and @laravel/passkeys 0.4, and run in Chromium with a virtual authenticator: adding a passkey, signing in from the email field and from the button, confirming, and removing one.

Install

Fortify 1.41 or later brings passkeys with it. In a new app, fortify:install writes everything below and its migrations. In an app that had Fortify before, update it and publish only the passkeys table; publishing all of Fortify's migrations again would add the two-factor columns a second time.

Terminal
composer require laravel/fortify
php artisan fortify:install
# Or, with Fortify already installed: composer update laravel/fortify, then
# php artisan vendor:publish --tag=passkeys-migrations
php artisan migrate

npm install @laravel/passkeys
php artisan bladewell:add auth-layout text-input password checkbox button toast

Turn the feature on

Enable passkeys with confirmPassword on, so adding or removing one asks who's there first. The passkey settings come from APP_URL; a passkey belongs to the domain it was made on. Browsers only allow passkeys over HTTPS, with localhost the one exception while you build.

config/fortify.php
// config/fortify.php: a fresh fortify:install already has all of this. Apps that had Fortify before passkeys
// add what's missing.
use Laravel\Fortify\Features;

return [
    // ...

    'limiters' => [
        'login' => 'login',
        'two-factor' => 'two-factor',
        // Without this line, the passkey routes aren't throttled at all.
        'passkeys' => 'passkeys',
    ],

    // Passkeys belong to one domain: these come from APP_URL, so it must be the address people visit,
    // https://app.example.com, not http://localhost. A passkey made on one domain doesn't work on another.
    'passkeys' => [
        'relying_party_id' => parse_url(config('app.url'), PHP_URL_HOST),
        'allowed_origins' => [config('app.url')],
        'timeout' => 60000,
    ],

    'features' => [
        // ...the features you already have, such as Features::registration(),
        Features::passkeys([
            // Asks for the password, or a passkey, before one is added or removed.
            'confirmPassword' => true,
        ]),
    ],
];

Your user model keeps everything it has, and gains Fortify's contract and trait. They add the passkeys() relationship and what Fortify needs to find the account a passkey belongs to.

app/Models/User.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;

// Your existing model: implement PasskeyUser, add the trait, and keep the rest.
class User extends Authenticatable implements PasskeyUser
{
    use HasFactory, Notifiable, PasskeyAuthenticatable;

    // ... your $fillable, $hidden, casts() and the rest, as they are.
}

Register the two screens, and the rate limiter the config names:

app/Providers/FortifyServiceProvider.php
namespace App\Providers;

use Illuminate\Cache\RateLimiting\Limit;
use Illuminate\Http\Request;
use Illuminate\Support\Facades\RateLimiter;
use Illuminate\Support\ServiceProvider;
use Laravel\Fortify\Fortify;

class FortifyServiceProvider extends ServiceProvider
{
    public function boot(): void
    {
        // ...what's already here: createUsersUsing(), the login limiter and the rest.

        Fortify::loginView(fn () => view('auth.login'));
        // Asked before a passkey is added or removed: the password, or a passkey.
        Fortify::confirmPasswordView(fn () => view('auth.confirm-password'));

        // The limiter the config's 'passkeys' names, as fortify:install writes it: per passkey once one is sent,
        // per visitor before that.
        RateLimiter::for('passkeys', function (Request $request) {
            $credentialId = $request->input('credential.id');

            return Limit::perMinute(10)->by(($credentialId ?: $request->session()->getId()).'|'.$request->ip());
        });
    }
}

The sign-in page

The usual email and password form, with two additions. The email field's autocomplete="username webauthn" lets the browser list saved passkeys among its suggestions, so picking one signs in without typing anything. And a button asks for a passkey outright, for browsers that can't suggest them or people who don't look there. Both send "remember me" as it's ticked.

resources/views/auth/login.blade.php
<x-widget.auth-layout variant="centered" title="Welcome back" subtitle="Sign in with a passkey, or your password.">
    <form method="POST" action="{{ route('login.store') }}" class="flex flex-col gap-5">
        @csrf
        {{-- webauthn in autocomplete: the browser lists the person's passkeys in this field's suggestions. --}}
        <x-widget.text-input name="email" type="email" label="Email" autocomplete="username webauthn" required autofocus />
        <x-widget.password name="password" label="Password" autocomplete="current-password" required />
        <x-widget.checkbox name="remember" label="Remember me" />
        <x-widget.button type="submit" class="w-full">Sign in</x-widget.button>

        {{-- Hidden until the script finds the browser can use passkeys. --}}
        <x-widget.button variant="secondary" class="w-full" data-passkey-login hidden
            data-options-url="{{ route('passkey.login-options') }}" data-submit-url="{{ route('passkey.login') }}">
            Sign in with a passkey
        </x-widget.button>
    </form>
</x-widget.auth-layout>

The buttons carry their routes from route(), so a route prefix or an app in a subfolder still works. The client sends Laravel's CSRF token from the <meta name="csrf-token"> tag, so keep that in your layout, along with <x-widget.toast /> for the messages below.

The script

One file runs every passkey button, and only pages that have one load it, so the rest of your app doesn't download the passkeys client. It shows them only where the browser can use passkeys, keeps a button busy while the device's prompt is open, and says nothing if the person closes that prompt. A page left open too long gets reloaded, once, with a note why: its session or CSRF token ran out, or the "confirm it's you" did. If the person signed in in another tab meanwhile, the page follows to where they'd be sent. Anything else becomes a toast that says what to do. After signing in, Fortify answers with where to go next and the client leaves the going to the page, so the script follows it.

resources/js/passkeys.js
// resources/js/passkeys.js. Only the pages with a passkey button load it, so the rest don't download the client.
// In resources/js/app.js, after Bladewell's own imports:
//   if (document.querySelector('[data-passkey-login], [data-passkey-confirm], [data-passkey-add-form]')) {
//       import('./passkeys');
//   }
import { PasskeyExistsError, Passkeys, ServerError, UnexpectedResponseError, UserCancelledError } from '@laravel/passkeys';

const supported = Passkeys.isSupported();
document.querySelectorAll('[data-passkey-login], [data-passkey-confirm], [data-passkey-add-form]').forEach((element) => {
    element.hidden = !supported;
});
document.querySelectorAll('[data-passkey-unsupported]').forEach((element) => {
    element.hidden = supported;
});

// Said after a reload: a passkey added on the last page, now that the list shows it, or why the page reloaded.
const added = sessionStorage.getItem('passkey-added');
const notice = JSON.parse(sessionStorage.getItem('passkey-notice'));
sessionStorage.removeItem('passkey-added');
sessionStorage.removeItem('passkey-notice');
if (added) {
    window.toast?.success(`Passkey "${added}" added.`);
}
// Only straight after the reload: the page it lands on may not load this file, and a later one shouldn't say it.
const reloadedAt = notice?.at ?? 0;
if (Date.now() - reloadedAt < 10_000) {
    window.toast?.info(notice.text);
}

// The button's own routes, from route() in the view, so a prefix or a subfolder still works.
const routes = (button) => ({ options: button.dataset.optionsUrl, submit: button.dataset.submitUrl });

// Read when the passkey is used, not when the page loads, so ticking the box late still counts.
const remember = (button) => () => button.form?.querySelector('[name="remember"]')?.checked ?? false;

// What went wrong, turned into what the person can do about it. Quiet if they closed the device's prompt.
function recover(error) {
    if (error instanceof UserCancelledError) {
        return;
    }
    // Signed in in another tab meanwhile: the sign-in routes send them on, and so does the page.
    if (error instanceof UnexpectedResponseError && error.redirected) {
        window.location.assign(error.url);

        return;
    }
    // The page outlived what it was given: the session (401, or 419 for its CSRF token) or the "confirm it's you"
    // (423). A fresh copy has what's missing, or shows the sign-in or confirm page instead. Not again straight after
    // such a reload: a server that keeps answering this way gets a message, not a loop.
    const expired = error instanceof ServerError && [401, 419, 423].includes(error.status);
    if (expired && Date.now() - reloadedAt > 10_000) {
        sessionStorage.setItem('passkey-notice', JSON.stringify({
            text: error.status === 423 ? 'Confirm it\'s you again, then try once more.' : 'The page had expired, so it was reloaded. Try again.',
            at: Date.now(),
        }));
        window.location.reload();

        return;
    }
    window.toast?.error(error instanceof PasskeyExistsError
        ? 'This device already has a passkey for your account.'
        : error instanceof ServerError && error.status === 429
            ? 'Too many tries. Wait a minute, then try again.'
            : expired
                ? 'This page has expired. Sign in again, or use your password.'
                : 'That didn\'t work. Try again, or use your password.');
}

// Runs one passkey prompt from a button: busy while it waits, and what went wrong, if anything, through recover().
async function run(button, prompt) {
    if (button.getAttribute('aria-busy') === 'true') {
        return;
    }
    button.setAttribute('aria-busy', 'true');
    try {
        await prompt();
    } catch (error) {
        recover(error);
    } finally {
        button.removeAttribute('aria-busy');
    }
}

// On the sign-in page, the browser offers saved passkeys in the email field's suggestions (autocomplete="… webauthn").
// It waits quietly until one is picked; the button below cancels it and asks outright.
const login = document.querySelector('[data-passkey-login]');
if (login && supported) {
    Passkeys.autofill({ remember: remember(login), routes: routes(login) })
        .then((response) => response && window.location.assign(response.redirect ?? '/'))
        .catch(recover);
}

document.addEventListener('click', (event) => {
    const login = event.target.closest('[data-passkey-login]');
    if (login) {
        run(login, async () => {
            // Fortify signs them in and answers with where to go; the client leaves the going to us.
            const { redirect } = await Passkeys.verify({ remember: remember(login), routes: routes(login) });
            window.location.assign(redirect ?? '/');
        });

        return;
    }

    const confirm = event.target.closest('[data-passkey-confirm]');
    if (confirm) {
        run(confirm, async () => {
            const { redirect } = await Passkeys.verify({ routes: routes(confirm) });
            window.location.assign(redirect ?? window.location.href);
        });
    }
});

// Adding one: the name, then the device's own prompt. A form, so Enter in the name works and the browser checks
// it's filled in; it's sent from here, not to a page.
document.addEventListener('submit', (event) => {
    const form = event.target.closest('[data-passkey-add-form]');
    if (!form) {
        return;
    }
    event.preventDefault();
    const name = form.elements.namedItem('name').value.trim();
    run(event.submitter ?? form, async () => {
        await Passkeys.register({ name, routes: routes(form) });
        sessionStorage.setItem('passkey-added', name);
        window.location.reload();
    });
});

Confirm it's you

Fortify asks for this before a passkey is added or removed. A passkey answers it as well as the password, which matters to people who have stopped typing theirs. If you followed the two-factor guide, this page replaces its password-only one and covers both.

resources/views/auth/confirm-password.blade.php
<x-widget.auth-layout variant="centered" title="Confirm it's you" subtitle="Before you change your passkeys.">
    <div class="flex flex-col gap-5">
        <x-widget.button class="w-full" data-passkey-confirm hidden
            data-options-url="{{ route('passkey.confirm-options') }}" data-submit-url="{{ route('passkey.confirm') }}">
            Confirm with a passkey
        </x-widget.button>

        <form method="POST" action="{{ route('password.confirm.store') }}" class="flex flex-col gap-5">
            @csrf
            <x-widget.password name="password" label="Or with your password" autocomplete="current-password" required />
            <x-widget.button type="submit" variant="secondary" class="w-full">Confirm</x-widget.button>
        </form>
    </div>
</x-widget.auth-layout>

The settings page

The page asks for that confirmation before it opens, not when a button is pressed: adding a passkey runs in the background, and a background request can't stop to show a form.

routes/web.php
// routes/web.php
use App\Http\Controllers\PasskeySettingsController;

// password.confirm: adding or removing a passkey needs a recent confirmation (confirmPassword), and the script calls
// those routes in the background, where it can't stop to ask. So the page asks first.
Route::get('/settings/passkeys', PasskeySettingsController::class)
    ->middleware(['auth', 'password.confirm'])
    ->name('settings.passkeys');

Fortify says what it just did with a status in the session; the controller turns that into words for the toast, and passes the list.

app/Http/Controllers/PasskeySettingsController.php
namespace App\Http\Controllers;

use Illuminate\Contracts\View\View;
use Illuminate\Http\Request;

class PasskeySettingsController
{
    public function __invoke(Request $request): View
    {
        // Fortify says what it just did with a status; the toast says it in words.
        if ($request->session()->get('status') === 'passkey-deleted') {
            $request->session()->now('success', 'Passkey removed.');
        }

        return view('settings.passkeys', [
            // What the list shows. The credential is read only so the model can name the authenticator from it
            // ("Google Password Manager"); the view never prints it.
            'passkeys' => $request->user()->passkeys()->latest()->get(['id', 'name', 'credential', 'last_used_at', 'created_at']),
        ]);
    }
}

Each passkey shows its name, the password manager or device that holds it where Fortify knows it ("Google Password Manager", "1Password", "Windows Hello"), and when it was last used. Removing one asks first with the button's confirm. Adding one is a small form the script sends: the name, then the device's own prompt.

resources/views/settings/passkeys.blade.php
<section class="grid max-w-xl gap-6" aria-labelledby="passkeys-heading">
    <h2 id="passkeys-heading" class="text-xl font-semibold">Passkeys</h2>
    <p>Sign in with your fingerprint, face or screen lock instead of a password. Each device or password manager keeps
        its own passkey.</p>

    @if ($passkeys->isNotEmpty())
        <ul class="divide-line grid divide-y rounded-lg border">
            @foreach ($passkeys as $passkey)
                <li class="flex items-center justify-between gap-4 p-4">
                    <div>
                        <p class="font-medium">{{ $passkey->name }}</p>
                        <p class="text-foreground/70 text-sm">
                            {{ $passkey->authenticator ? $passkey->authenticator.' · ' : '' }}Added {{ $passkey->created_at->toFormattedDateString() }}
                            · {{ $passkey->last_used_at ? 'Last used '.$passkey->last_used_at->diffForHumans() : 'Not used yet' }}
                        </p>
                    </div>
                    <form method="POST" action="{{ route('passkey.destroy', $passkey) }}">
                        @csrf
                        @method('DELETE')
                        <x-widget.button type="submit" variant="neutral" size="sm" :confirm="'Remove the passkey “'.$passkey->name.'”?'" confirm-message="You won't be able to sign in with it any more.">
                            Remove<span class="sr-only"> {{ $passkey->name }}</span>
                        </x-widget.button>
                    </form>
                </li>
            @endforeach
        </ul>
    @else
        <p class="text-foreground/70">You haven't added a passkey yet.</p>
    @endif

    {{-- Adding one runs in the browser: the name, then the device's own prompt. Hidden until the script finds the
         browser can use passkeys; the note below shows instead when it can't. --}}
    <form class="flex flex-col gap-4" data-passkey-add-form hidden
        data-options-url="{{ route('passkey.registration-options') }}" data-submit-url="{{ route('passkey.store') }}">
        <x-widget.text-input name="name" label="Name this passkey" placeholder="e.g. Work laptop" maxlength="255" required />
        <x-widget.button type="submit" :submit-guard="false" class="self-start">Add a passkey</x-widget.button>
    </form>
    <p class="text-foreground/70" data-passkey-unsupported hidden>This browser can't use passkeys. Try another browser or device.</p>
</section>

Before you ship it

  • The domain. Passkeys made on localhost or a staging domain don't work in production. Set APP_URL to the real address before anyone adds one there; changing the domain later leaves every passkey behind.
  • Two-factor. A passkey sign-in skips Fortify's two-factor challenge. That's by design: a passkey already needs the device and the person's fingerprint, face or PIN. The password still asks for the code.
  • Who may sign in. To stop some accounts using passkeys (suspended, not yet verified), give Laravel\Passkeys\Passkeys::authorizeLoginUsing() a callback in a service provider.
  • Keep the password. People lose phones. Leave the password, and two-factor if you have it, as the way back in.
  • Livewire. The script runs when a page loads. Link to these pages normally, not with wire:navigate, so it runs on each of them.