Laravel Updates

Laravel 13 Passkey Authentication: Setup Guide for Modern Apps

Learn how to set up Passkey authentication in Laravel 13, step-by-step guide covering WebAuthn, Fortify integration, database setup, frontend wiring, and production tips for passwordless login.

3 hours ago · 8 mins read
Summarize and analyze this article with:
Share this

Passwords are broken. Not metaphorically but literally. They get stolen, reused, phished, and leaked in data breaches every single day. And yet, most web apps still ask users to "create a password with at least 8 characters and one uppercase letter."

Laravel 13 just made that excuse obsolete.

With Passkey Authentication now baked directly into Laravel 13's starter kits and Fortify, you can offer your users a login experience using Face ID, fingerprint, or a hardware security key , zero passwords required. No "Forgot Password" flow. No bcrypt. No breach exposure.

In this blog, we'll walk through exactly how Passkeys work, why they matter, and how to set them up in your Laravel 13 app from scratch.


What Is a Passkey (And Why Should You Care)?

Passkey is a credential based on the WebAuthn (Web Authentication) standard, a W3C spec built on public-key cryptography. Instead of a password, the user authenticates using their device's biometric scanner or a hardware key (like YubiKey).

Here's the elegant part: the private key never leaves the user's device. Your server only stores the public key. During login, the server sends a challenge, the device signs it with the private key, and your server verifies the signature using the stored public key.

There's nothing to steal from your database because you never store a secret.

Why It's Better Than Passwords

FeaturePasswordsPasskeys
Phishing-resistant
No database secret stored
Works offline
UX frictionHighNear-zero
"Forgot Password" flow needed
Syncs across devices (iCloud/Google)

What Changed in Laravel 13?

In earlier Laravel versions, adding Passkeys required wiring up third-party packages manually , Spatie's laravel-passkeys or Laragear's WebAuthn package — with significant configuration.

Laravel 13 changes that entirely.

  • Passkey support is now integrated into Laravel Fortify and all new starter kits (Breeze, Jetstream)
  • New apps scaffold Passkey support automatically
  • No heavy third-party library required for basic use
  • Works out of the box with Face ID, Touch ID, Windows Hello, and hardware security keysIf you're starting a new app, Passkeys are enabled from day one. If you're upgrading, this guide covers both paths.

Prerequisites

Before we begin, make sure you have:

  • PHP 8.2+
  • Laravel 13.x installed
  • HTTPS enabled locally (WebAuthn requires a secure context — use Laravel Valet, Herd, or localhost)
  • A modern browser (Chrome, Safari, Firefox all support WebAuthn)
  • Basic familiarity with Laravel Auth and migrations

Step 1 — Create a Fresh Laravel 13 App

composer create-project laravel/laravel my-app
cd my-app

Install the Breeze starter kit with Passkey support:

composer require laravel/breeze --dev
php artisan breeze:install

When prompted for the stack, choose blade or livewire. Laravel 13's Breeze now includes Passkey scaffolding automatically.

Run migrations:

php artisan migrate

Step 2 — Understanding the Database Structure

Laravel 13 creates a passkeys table alongside your users table. Here's what it stores:

Schema::create('passkeys', function (Blueprint $table) {
    $table->id();
    $table->foreignId('user_id')->constrained()->cascadeOnDelete();
    $table->string('name'); // e.g., "iPhone 15 Face ID"
    $table->text('credential_id');
    $table->text('public_key');       // stored public key (not secret)
    $table->unsignedBigInteger('counter')->default(0);
    $table->json('transports')->nullable();
    $table->timestamps();
    $table->timestamp('last_used_at')->nullable();
});

The credential_id uniquely identifies the authenticator device. The counter value increments on every login — if a server receives a counter value lower than expected, it flags a cloned authenticator.

One user can have multiple passkeys — their phone, laptop, and a YubiKey, for example. Always store them all.


Step 3 — Passkey Registration Flow

When a user wants to add a passkey to their account, two things happen:

3a. Server generates a registration challenge

// PasskeyController.php
use Illuminate\Http\Request;
use App\Models\Passkey;

public function registerOptions(Request $request)
{
    $options = [
        'challenge' => base64_encode(random_bytes(32)),
        'rp' => [
            'name' => config('app.name'),
            'id'   => parse_url(config('app.url'), PHP_URL_HOST),
        ],
        'user' => [
            'id'          => base64_encode((string) $request->user()->id),
            'name'        => $request->user()->email,
            'displayName' => $request->user()->name,
        ],
        'pubKeyCredParams' => [
            ['type' => 'public-key', 'alg' => -7],   // ES256
            ['type' => 'public-key', 'alg' => -257],  // RS256
        ],
        'authenticatorSelection' => [
            'userVerification' => 'required',
        ],
        'timeout' => 60000,
    ];

    session(['passkey_register_challenge' => $options['challenge']]);

    return response()->json($options);
}

3b. Frontend uses SimpleWebAuthn to prompt the device

npm install @simplewebauthn/browser
// resources/js/passkey-register.js
import { startRegistration } from '@simplewebauthn/browser';

async function registerPasskey() {
    // 1. Get challenge from server
    const options = await fetch('/passkeys/register/options').then(r => r.json());

    // 2. Prompt device (triggers Face ID / fingerprint)
    const credential = await startRegistration(options);

    // 3. Send result back to server
    const response = await fetch('/passkeys/register', {
        method: 'POST',
        headers: { 'Content-Type': 'application/json', 'X-CSRF-TOKEN': csrfToken },
        body: JSON.stringify(credential),
    });

    if (response.ok) {
        alert('Passkey registered successfully!');
    }
}

3c. Server verifies and stores the credential

public function register(Request $request)
{
    $credential = $request->validate([
        'id'       => 'required|string',
        'rawId'    => 'required|string',
        'response' => 'required|array',
        'type'     => 'required|string',
    ]);

    // Verify the challenge matches
    $expectedChallenge = session('passkey_register_challenge');

    // Decode and verify the attestation (use spatie/laravel-passkeys
    // or Fortify's built-in handler in Laravel 13 for full verification)

    Passkey::create([
        'user_id'       => $request->user()->id,
        'name'          => $request->input('name', 'My Device'),
        'credential_id' => $credential['id'],
        'public_key'    => json_encode($credential['response']),
        'counter'       => 0,
    ]);

    session()->forget('passkey_register_challenge');

    return response()->json(['success' => true]);
}

Step 4 — Passkey Authentication (Login) Flow

Login is even simpler than registration.

4a. Server generates an authentication challenge

public function loginOptions(Request $request)
{
    // For usernameless flow, allowCredentials can be empty
    // For username-first flow, fetch stored passkeys for this user

    $options = [
        'challenge'        => base64_encode(random_bytes(32)),
        'rpId'             => parse_url(config('app.url'), PHP_URL_HOST),
        'userVerification' => 'required',
        'timeout'          => 60000,
    ];

    session(['passkey_auth_challenge' => $options['challenge']]);

    return response()->json($options);
}

4b. Frontend triggers the device authenticator

import { startAuthentication } from '@simplewebauthn/browser';

async function loginWithPasskey() {
    const options = await fetch('/passkeys/login/options').then(r => r.json());

    const credential = await startAuthentication(options);

    const result = await fetch('/passkeys/login', {
        method: 'POST',
        headers: { 'Content-Type': 'application/json', 'X-CSRF-TOKEN': csrfToken },
        body: JSON.stringify(credential),
    });

    if (result.ok) {
        window.location.href = '/dashboard';
    }
}

4c. Server verifies the signature and logs the user in

public function login(Request $request)
{
    $credentialId = $request->input('id');

    $passkey = Passkey::where('credential_id', $credentialId)->firstOrFail();

    // Verify challenge + signature using stored public key
    // Laravel 13 Fortify handles this internally via its WebAuthn pipeline
    // For manual verification, use spatie/laravel-passkeys

    // On success:
    Auth::loginUsingId($passkey->user_id);

    session()->forget('passkey_auth_challenge');

    return response()->json(['verified' => true]);
}

Step 5 — Using Laravel 13's Built-In Fortify Integration

If you're using Laravel Fortify (which powers Jetstream), Laravel 13 makes this even cleaner. Just enable it in config/fortify.php:

// config/fortify.php
'features' => [
    Features::registration(),
    Features::resetPasswords(),
    Features::emailVerification(),
    Features::passkeys(),   // ← Add this line
],

Fortify handles the WebAuthn challenge generation, signature verification, counter validation, and session management automatically. You just wire up the routes and views.


Step 6 — Routes

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

Route::middleware('auth')->group(function () {
    Route::get('/passkeys/register/options', [PasskeyController::class, 'registerOptions']);
    Route::post('/passkeys/register', [PasskeyController::class, 'register']);
    Route::delete('/passkeys/{passkey}', [PasskeyController::class, 'destroy']);
});

Route::middleware('guest')->group(function () {
    Route::get('/passkeys/login/options', [PasskeyController::class, 'loginOptions']);
    Route::post('/passkeys/login', [PasskeyController::class, 'login']);
});

Step 7 — Managing Passkeys in User Profile

Users should be able to name, view, and revoke their passkeys. Here's a minimal Blade snippet:

{{-- resources/views/profile/passkeys.blade.php --}}

<h3>Your Passkeys</h3>

@foreach(auth()->user()->passkeys as $passkey)
    <div class="passkey-item">
        <span>{{ $passkey->name }}</span>
        <span class="text-muted">Last used: {{ $passkey->last_used_at?->diffForHumans() ?? 'Never' }}</span>
        <form method="POST" action="/passkeys/{{ $passkey->id }}">
            @csrf @method('DELETE')
            <button type="submit">Remove</button>
        </form>
    </div>
@endforeach

<button onclick="registerPasskey()">+ Add New Passkey</button>

Common Mistakes to Avoid

  • Not using HTTPS locally — WebAuthn throws a hard error on non-secure origins. Use Laravel Herd or set up a local SSL cert.
  • Storing the private key — you should never receive it. If your flow sends the private key to the server, something is architecturally wrong.
  • Not validating the counter — always check that the stored counter is less than the received counter to detect cloned authenticators.
  • Single passkey only — always allow multiple passkeys per user. Devices get lost or replaced.
  • No fallback — always offer an email OTP or magic link as a fallback for users without passkey-compatible devices.

Production Checklist

Before going live, verify the following:

  • HTTPS enforced on all auth routes
  • APP_URL in .env matches your domain exactly (WebAuthn uses this as rpId)
  • Passkeys table indexed on credential_id for fast lookups
  • Rate limiting applied to /passkeys/login and /passkeys/login/options
  • Users can add, name, and revoke passkeys from their profile
  • Fallback authentication method available (magic link or OTP)
  • Passkey last_used_at timestamp updated on every successful login

Wrapping Up

Passkey authentication isn't a future feature, it's a present one, and Laravel 13 makes it the default path forward. The combination of zero password storage, phishing resistance, and a frictionless UX makes it the most meaningful authentication upgrade the web has seen in a decade.

Your users won't need to remember anything. They'll just tap their finger, look at their camera, or press a key , and they're in.

That's the kind of app experience that builds trust, reduces support tickets, and keeps users coming back.


Built something cool with Laravel 13 Passkeys? Drop it in the comments, we'd love to feature real-world examples on AppMonkey.

Read next

Laravel 13 Is Here: And It's Smarter, Safer And AI-Ready

Laravel 13 is officially out! Discover all the new features, from the built-in AI SDK and passkey authentication to PHP 8.3 support and zero breaking changes. A must-read for every PHP developer.

Mar 24 · 1 min read