How to add WebAuthn Passkeys To Backpack For Laravel Admin Panel

Want to make your Laravel Backpack admin panel more secure with a unique login experience for your admins? I'll show you how to add Pas...

Kidd Tang
Kidd Tang
Share:

Want to make your Laravel Backpack admin panel more secure with a unique login experience for your admins?

I'll show you how to add Passkeys - it's like using your phone's face scanning/fingerprint to log in. No more remembering passwords! It uses FIDO2, the same technology that big companies like Google and Apple trust.

Follow along with this step-by-step guide, and you'll have Passkeys working in your admin panel - no deep technical knowledge required! 👇

DEMO GIF

Prerequisite

  • Laravel 11 and Backpack v6 installed
  • Your site is running on đź”’ HTTPS. It is a requirement for Passkeys.

If you are developing with Laravel Sail, you may check this guide to upgrade HTTP to HTTPS.

Let's get started

Step 1: Install WebAuthn Library

composer require web-auth/webauthn-lib:^5.0

Step 2: Create the Serializer helper class

Create the file: app/Support/JsonSerializer.php. This helper class serializes and deserializes the WebAuthn credential data for passkey authentication.

<?php

/**
 * WebAuthn JSON Serializer
 *
 * Originally from Laracasts "Adding Passkeys to Your Laravel App" course
 *
 * @author Luke Raymond Downing <github.com/lukeraymonddowning>
 *
 * @source https://github.com/laracasts/adding-passkeys-to-your-laravel-app/blob/v5/app/Support/JsonSerializer.php
 */

namespace App\Support;

use Webauthn\AttestationStatement\AttestationStatementSupportManager;
use Webauthn\Denormalizer\WebauthnSerializerFactory;

class JsonSerializer
{
    public static function serialize(object $data): string
    {
        return (new WebauthnSerializerFactory(AttestationStatementSupportManager::create()))
            ->create()
            ->serialize($data, 'json');
    }

    /**
     * @template TReturn
     *
     * @param  class-string<TReturn>  $into
     * @return TReturn
     */
    public static function deserialize(string $json, string $into)
    {
        return (new WebauthnSerializerFactory(AttestationStatementSupportManager::create()))
            ->create()
            ->deserialize($json, $into, 'json');
    }
}

Step 3: Create a Passkey Model

php artisan make:model Passkey --migration

Here is the essential migration schema for the passkeys table.

public function up(): void
{
    Schema::create('passkeys', function (Blueprint $table) {
        $table->id();
        $table->foreignId('user_id')->constrained('users')->cascadeOnDelete();
        $table->string('name');
        $table->binary('credential_id');
        $table->json('data');
        $table->timestamps();
    });
}

Reminder: Run php artisan migrate to create passkeys table

The corresponding Passkey model uses the helper class for the accessor & mutator to correctly parse the credential data.

<?php

namespace App\Models;

use App\Support\JsonSerializer;
use Illuminate\Database\Eloquent\Casts\Attribute;
use Illuminate\Database\Eloquent\Model;
use Illuminate\Database\Eloquent\Relations\BelongsTo;
use Webauthn\PublicKeyCredentialSource;

class Passkey extends Model
{
    protected $fillable = [
        'name',
        'credential_id',
        'data',
    ];

    /*
    |--------------------------------------------------------------------------
    | RELATIONS
    |--------------------------------------------------------------------------
    */

    public function user(): BelongsTo
    {
        return $this->belongsTo(User::class);
    }

    /*
    |--------------------------------------------------------------------------
    | ACCESSORS / MUTATORS
    |--------------------------------------------------------------------------
    */
    public function data(): Attribute
    {
        return new Attribute(
            get: fn (string $value) => JsonSerializer::deserialize($value, PublicKeyCredentialSource::class),
            set: fn (PublicKeyCredentialSource $value) => [
                'credential_id' => $value->publicKeyCredentialId,
                'data' => JsonSerializer::serialize($value),
            ],
        );
    }
}

Don’t forget to add the relationship in the User class.

class User extends Authenticatable
{

    // Existing code ...

    public function passkeys(): HasMany
    {
        return $this->hasMany(Passkey::class);
    }  
}

Step 4: Set Up the Passkey Registration Form

Extend the \Backpack\CRUD\app\Http\Controllers\MyAccountController, as we are going to add a Passkey management section below the update password section.

In your app/Providers/AppServiceProvider.php

public function boot(): void
{
    // some other existing code ...

    // Customize Controllers
    $this->app->bind(
        \Backpack\CRUD\app\Http\Controllers\MyAccountController::class,
        \App\Http\Controllers\Admin\MyAccountController::class
    );
}

I am injecting the user’s $passkeys and $passkey_register_options (which contains the Attestation options) to the view in the extended MyAccountController. These attestation options are minimum to work. You can check the documentation to learn more.

app/Http/Controllers/Admin/MyAccountController.php

<?php

namespace App\Http\Controllers\Admin;

use App\Support\JsonSerializer;
use Illuminate\Support\Facades\Session;
use Illuminate\Support\Str;
use Webauthn\Exception\InvalidDataException;
use Webauthn\PublicKeyCredentialCreationOptions;
use Webauthn\PublicKeyCredentialRpEntity;
use Webauthn\PublicKeyCredentialUserEntity;

class MyAccountController extends \Backpack\CRUD\app\Http\Controllers\MyAccountController
{
    /**
     * @throws InvalidDataException
     */
    public function getAccountInfoForm()
    {
        $this->data['title'] = trans('backpack::base.my_account');
        $this->data['user'] = $this->guard()->user();

        $this->data['passkeys'] = $this->guard()->user()->passkeys()->select(['id', 'name', 'created_at'])->get();

        Session::flash('passkey_register_options', $this->getRegisterOptions());

        return view(backpack_view('my_account'), $this->data);
    }

    /**
     * Generate WebAuthn registration options for credential creation.
     * Necessary data including relying party details, user information, and a random challenge.
     *
     * @throws InvalidDataException
     */
    private function getRegisterOptions()
    {
        return new PublicKeyCredentialCreationOptions(
            rp: new PublicKeyCredentialRpEntity(
                name: config('app.name'),
                id: parse_url(config('app.url'), PHP_URL_HOST),
            ),
            user: new PublicKeyCredentialUserEntity(
                name: $this->guard()->user()->email,
                id: $this->guard()->user()->id,
                displayName: $this->guard()->user()->name,
            ),
            challenge: Str::random(),
        );
    }
}

Now, we need to work on blade views. Copy vendor/backpack/theme-tabler/resources/views/my_account.blade.php to resources/views/vendor/backpack/theme-tabler/my_account.blade.php to override the original view, and add the passkey section after CHANGE PASSWORD FORM.

{{-- Passkeys Section --}}
@include('vendor.backpack.theme-tabler.passkeys')

Create your own passkeys.blade.php file, or use mine as your starting point.

<div class="col-lg-8 mb-4">
    <div class="card">
        <div class="card-header">
            <h3 class="card-title">Manage Passkeys</h3>
        </div>

        <div class="card-body backpack-profile-form bold-labels">
            {{-- REGISTERED PASSKEYS LIST --}}
            @if(count($passkeys ?? []) > 0)
                <div class="table-responsive">
                    <table class="table" style="width:100%">
                        <thead>
                        <tr>
                            <th>Name</th>
                            <th>Created At</th>
                            <th>Actions</th>
                        </tr>
                        </thead>
                        <tbody>
                        @foreach($passkeys as $passkey)
                            <tr>
                                <td>{{ $passkey->name }}</td>
                                <td>{{ $passkey->created_at->diffForHumans() }}</td>
                                <td>
                                    <button type="button" class="btn btn-sm btn-danger">
                                        <i class="la la-trash"></i> Delete
                                    </button>
                                </td>
                            </tr>
                        @endforeach
                        </tbody>
                    </table>
                </div>
            @else
                <div class="alert alert-info">
                    No passkeys registered yet.
                </div>
            @endif

            {{-- REGISTER NEW PASSKEY --}}
            <form id="passkey-form" class="form mt-4" action="{{ route('backpack.passkey.create') }}" method="POST">
                @csrf
                
                <div class="row">
                    <div class="col-md-6 form-group">
                        <label class="required">Name</label>
                        <input required class="form-control" type="text" name="name" value="{{ old('name') }}"
                               placeholder="Enter a name for this passkey">
                    </div>
                </div>

                <input type="hidden" id="passkey" name="passkey" value="">

                <div class="mt-3">
                    <button type="submit" class="btn btn-success">
                        <i class="la la-key"></i> Register New Passkey
                    </button>
                </div>
            </form>
        </div>
    </div>
</div>

Step 5: Create User Passkey

First, add the routes to routes/backpack/custom.php.

use App\Http\Controllers\Admin\PasskeyController;

Route::group([
    'prefix' => config('backpack.base.route_prefix', 'admin'),
    'middleware' => array_merge(
        (array) config('backpack.base.web_middleware', 'web'),
        (array) config('backpack.base.middleware_key', 'admin')
    ),
    'namespace' => 'App\Http\Controllers\Admin',
], function () { // custom admin routes
    Route::post('passkey/create', [PasskeyController::class, 'create'])->name('backpack.passkey.create');
    // other routes ...
});

Then, create app/Http/Controllers/Admin/PasskeyController.php. It will handle the Passkey creation and deletion.

The create method accepts the name that user provided and the Passkey that is returned by the javascript package @simplewebauthn/browser with the registration option provided in the session.

<?php

namespace App\Http\Controllers\Admin;

use App\Http\Controllers\Controller;
use App\Support\JsonSerializer;
use Illuminate\Http\RedirectResponse;
use Illuminate\Http\Request;
use Illuminate\Support\Facades\Session;
use Illuminate\Validation\ValidationException;
use Prologue\Alerts\Facades\Alert;
use Webauthn\AuthenticatorAttestationResponse;
use Webauthn\AuthenticatorAttestationResponseValidator;
use Webauthn\CeremonyStep\CeremonyStepManagerFactory;
use Webauthn\PublicKeyCredential;
use Webauthn\PublicKeyCredentialCreationOptions;

class PasskeyController extends Controller
{
    public function create(Request $request): RedirectResponse
    {
        $user = $this->guard()->user();

        $validated = $request->validate([
            'name' => ['required', 'string', 'between:1,255'],
            'passkey' => ['required', 'json'],
        ]);

        // Deserialize the public key credential from the request
        $publicKeyCredential = JsonSerializer::deserialize($validated['passkey'], PublicKeyCredential::class);

        if (! $publicKeyCredential->response instanceof AuthenticatorAttestationResponse) {
            return redirect()->guest(backpack_url('login'));
        }

        try {
            $publicKeyCredentialSource = AuthenticatorAttestationResponseValidator::create(
                (new CeremonyStepManagerFactory)->creationCeremony(),
            )->check(
                authenticatorAttestationResponse: $publicKeyCredential->response,
                publicKeyCredentialCreationOptions: Session::get('passkey_register_options'),
                host: $request->getHost(),
            );
        } catch (\Throwable $e) {
            throw ValidationException::withMessages([
                'name' => 'The given passkey is invalid.',
            ])->errorBag('createPasskey');
        }

        $result = $user->passkeys()->create([
            'name' => $validated['name'],
            'data' => $publicKeyCredentialSource,
        ]);

        if ($result) {
            Alert::success('Passkey created successfully')->flash();
        } else {
            Alert::error(trans('Failed to create Passkey'))->flash();
        }

        return redirect()->back();
    }

    protected function guard()
    {
        return backpack_auth();
    }
}

Finally, add the script at the top of passkey.blade.php created in the previous step.

@section('after_scripts')
@basset('https://unpkg.com/@simplewebauthn/[email protected]/dist/bundle/index.umd.min.js')
<script>
    const form = document.getElementById('passkey-form');
    const registrationOptions = {!! json_encode(\App\Support\JsonSerializer::serialize(session('passkey_register_options'))) !!};

    form.addEventListener('submit', async function (e) {
        e.preventDefault();

        // Name validation with length check
        const name = document.querySelector('input[name="name"]').value.trim();
        if (!name || name.length < 1 || name.length > 255) {
            alert('Name must be between 1 and 255 characters');
            return;
        }

        try {
            const options = JSON.parse(registrationOptions);

            const attResp = await SimpleWebAuthnBrowser.startRegistration(options);

            // Add the attestation to the form
            document.getElementById('passkey').value = JSON.stringify(attResp);

            this.submit();
        } catch (error) {
            console.error('Error:', error);
            alert('Failed to register passkey: ' + error.message);
        }
    });
</script>
@endsection

Now, you should be able to create your first passkey! 🎉

image

Step 6: Upgrade Login Form

Let’s upgrade the login form to accept the user’s email and provide the challenge for the passkey authentication process.

Extend the \Backpack\CRUD\app\Http\Controllers\Auth\LoginController, as we are going to provide the assertion options for the WebAuthn authentication challenge.

Registration involves Attestation where a new security key is registered, while authentication uses Assertion where an existing security key proves its identity.

In your app/Providers/AppServiceProvider.php, add these lines...

public function boot(): void
{
    // some other existing code ...

    $this->app->bind(
        \Backpack\CRUD\app\Http\Controllers\Auth\LoginController::class,
        \App\Http\Controllers\Admin\Auth\LoginController::class
    );
}

Then create app/Http/Controllers/Admin/Auth/LoginController.php and override the showLoginForm() function.

<?php

namespace App\Http\Controllers\Admin\Auth;

use App\Models\Passkey;
use Illuminate\Http\Request;
use Illuminate\Support\Facades\Session;
use Illuminate\Support\Str;
use Webauthn\PublicKeyCredentialRequestOptions;
use Webauthn\PublicKeyCredentialSource;

class LoginController extends \Backpack\CRUD\app\Http\Controllers\Auth\LoginController
{
    /**
     * Extend the application's login form.
     *
     * @return \Illuminate\Contracts\View\View
     */
    public function showLoginForm()
    {
        $this->data['title'] = trans('backpack::base.login'); // set the page title
        $this->data['username'] = $this->username();

        $this->data['valid_passkey_challenge'] = Session::has('passkey_authentication_options');

        // Only keep passkey authentication options if username exists
        if (old($this->username())) {
            Session::keep('passkey_authentication_options');
        }

        return view(backpack_view('auth.login'), $this->data);
    }

    /**
     * Generate WebAuthn authentication options for passkey-based login.
     */
    public function authenticateOptions(Request $request)
    {
        $validated = $request->validate([
            'email' => ['required', 'email', 'max:255'],
        ]);

        $allowedCredentials = $request->query('email')
            ? Passkey::whereRelation('user', 'email', $validated['email'])
                ->get()
                ->map(fn (Passkey $passkey) => $passkey->data)
                ->map(fn (PublicKeyCredentialSource $publicKeyCredentialSource) => $publicKeyCredentialSource->getPublicKeyCredentialDescriptor())
                ->all()
            : [];

        $options = new PublicKeyCredentialRequestOptions(
            challenge: Str::random(),
            rpId: parse_url(config('app.url'), PHP_URL_HOST),
            allowCredentials: $allowedCredentials,
        );

        Session::flash('passkey_authentication_options', $options);

        return redirect()->back()
            ->withInput(['email' => $validated['email']]);
    }
}

Add these lines to routes/backpack/custom.php to expose the authenticateOptions() function.

use App\Http\Controllers\Admin\Auth\LoginController;

// --------------------------
// Passkey Sign-In Routes
// --------------------------
Route::group([
    'prefix' => config('backpack.base.route_prefix', 'admin'),
    'middleware' => array_merge(
        (array) config('backpack.base.web_middleware', 'web'),
        ['throttle:5,1'] // 5 attempts per minute
    ),
    'namespace'  => 'App\Http\Controllers\Admin\Auth',
], function () {
    Route::post('passkey/login', [LoginController::class, 'authenticateOptions'])->name('passkey.login');
});

Finally, create the blade view to render the necessary script to handle WebAuthn assertion.

Add the script snippet below to the @section('after_scripts') section in the copied resources/views/vendor/backpack/theme-tabler/auth/login/inc/form.blade.php

@basset('https://unpkg.com/@simplewebauthn/[email protected]/dist/bundle/index.umd.min.js')
<script>
  // Setup passkey authentication
  const $emailInput = $(`input[name="{{ $username }}"]`);
  const $passkeyButton = $('#btn-passkey-auth');

  @if(!$valid_passkey_challenge)
  // Email validation for initial passkey button
  $emailInput.on('input', () => {
      const isValid = $emailInput.val().includes('@') && $emailInput.val().includes('.');
      $passkeyButton.prop('disabled', !isValid).toggleClass('d-none', !isValid);
  });

  // Initial check for pre-filled email
  $emailInput.trigger('input');

  // Handle passkey button click with form submision
  $passkeyButton.on('click', () => {
      $passkeyButton.prop('disabled', true);
      $('body').css('cursor', 'wait');

      $('<form>', {
          method: 'POST',
          action: '{{ route('passkey.login') }}',
          html: `
          <input type="hidden" name="_token" value="${$('meta[name="csrf-token"]').attr('content')}">
          <input type="hidden" name="email" value="${$emailInput.val()}">
      `
      }).appendTo('body').submit();
  });
  @else
  // Handle the actual authentication
  $passkeyButton.on('click', async () => {
      // TODO: Passkey Authentication
  });
  @endif
</script>

The <form> in the blade needs to be updated for better UX. When a valid email is provided, a button "Sign in with passkey" will appear. Once the assertion challenge is received from the server, the password-related elements will disappear. While not the optimal UX, it should suffice for demonstration purposes.

<form method="POST" action="{{ route('backpack.auth.login') }}" autocomplete="off" novalidate="">
    @csrf
    <div class="mb-3">
        <label class="form-label" for="{{ $username }}">{{ trans('backpack::base.'.strtolower(config('backpack.base.authentication_column_name'))) }}</label>
        <input autofocus tabindex="1" type="text" name="{{ $username }}" value="{{ old($username) }}" id="{{ $username }}" class="form-control {{ $errors->has($username) ? 'is-invalid' : '' }}" {{ $valid_passkey_challenge ? 'disabled' : '' }}>
        @if ($errors->has($username))
            <div class="invalid-feedback">{{ $errors->first($username) }}</div>
        @endif
    </div>
    <div class="mb-2 {{ $valid_passkey_challenge ? 'd-none' : '' }}">
        <label class="form-label" for="password">
            {{ trans('backpack::base.password') }}
        </label>
        <div class="input-group input-group-flat password-visibility-toggler">
            <input tabindex="2" type="password" name="password" id="password" class="form-control {{ $errors->has('password') ? 'is-invalid' : '' }}" value="">
            @if(backpack_theme_config('options.showPasswordVisibilityToggler'))
            <span class="input-group-text p-0 px-2">
                <a href="#" data-input-type="text" class="link-secondary p-2" data-bs-toggle="tooltip" aria-label="{{ trans('backpack.theme-tabler::theme-tabler.password-show') }}" data-bs-original-title="{{ trans('backpack.theme-tabler::theme-tabler.password-show') }}">
                    <svg xmlns="http://www.w3.org/2000/svg" class="icon icon-tabler icon-tabler-eye" width="24" height="24" viewBox="0 0 24 24" stroke-width="2" stroke="currentColor" fill="none" stroke-linecap="round" stroke-linejoin="round"><path stroke="none" d="M0 0h24v24H0z" fill="none"></path><path d="M10 12a2 2 0 1 0 4 0a2 2 0 0 0 -4 0"></path><path d="M21 12c-2.4 4 -5.4 6 -9 6c-3.6 0 -6.6 -2 -9 -6c2.4 -4 5.4 -6 9 -6c3.6 0 6.6 2 9 6"></path></svg>
                </a>
                <a href="#" data-input-type="password" class="link-secondary p-2 d-none" data-bs-toggle="tooltip" aria-label="{{ trans('backpack.theme-tabler::theme-tabler.password-hide') }}" data-bs-original-title="{{ trans('backpack.theme-tabler::theme-tabler.password-hide') }}">
                    <svg xmlns="http://www.w3.org/2000/svg" class="icon icon-tabler icon-tabler-eye-off" width="24" height="24" viewBox="0 0 24 24" stroke-width="2" stroke="currentColor" fill="none" stroke-linecap="round" stroke-linejoin="round"><path stroke="none" d="M0 0h24v24H0z" fill="none"/><path d="M10.585 10.587a2 2 0 0 0 2.829 2.828" /><path d="M16.681 16.673a8.717 8.717 0 0 1 -4.681 1.327c-3.6 0 -6.6 -2 -9 -6c1.272 -2.12 2.712 -3.678 4.32 -4.674m2.86 -1.146a9.055 9.055 0 0 1 1.82 -.18c3.6 0 6.6 2 9 6c-.666 1.11 -1.379 2.067 -2.138 2.87" /><path d="M3 3l18 18" /></svg>
                </a>
            </span>
            @endif
        </div>
        @if ($errors->has('password'))
            <div class="invalid-feedback">{{ $errors->first('password') }}</div>
        @endif
    </div>
    <div class="d-flex justify-content-between align-items-center mb-2 {{ $valid_passkey_challenge ? 'd-none' : '' }}">
        <label class="form-check mb-0">
            <input name="remember" tabindex="3" type="checkbox" class="form-check-input">
            <span class="form-check-label">{{ trans('backpack::base.remember_me') }}</span>
        </label>
        @if (backpack_users_have_email() && backpack_email_column() == 'email' && config('backpack.base.setup_password_recovery_routes', true))
            <div class="form-label-description">
                <a tabindex="4" href="{{ route('backpack.auth.password.reset') }}">{{ trans('backpack::base.forgot_your_password') }}</a>
            </div>
        @endif
    </div>
    <div class="form-footer">
        <button tabindex="5" id="btn-passkey-auth" type="button"
                class="btn w-100 mb-2 {{ $valid_passkey_challenge ? 'btn-primary' : 'd-none btn-success' }}">
            {{ $valid_passkey_challenge ? 'Login with passkey' : 'I\'ve passkey registered!' }}
        </button>
        <button tabindex="5" type="submit" class="btn btn-primary w-100 {{ $valid_passkey_challenge ? 'd-none' : '' }}">{{ trans('backpack::base.login') }}</button>
    </div>
</form>

Simple Passkey Frontend Demo

image

Step 7: Authenticate using Passkey

If everything works correctly, congratulations! Also, thank you for following the tutorial up to this point. You’ve reached the last part!

Add the authentication endpoint in the route file routes/backpack/custom.php

Route::post('passkeys/authenticate', [LoginController::class, 'authenticatePasskey'])->name('passkey.authenticate');

with the corresponding controller method in LoginController:

use App\Support\JsonSerializer;
use Illuminate\Validation\ValidationException;
use Webauthn\AuthenticatorAssertionResponse;
use Webauthn\AuthenticatorAssertionResponseValidator;
use Webauthn\CeremonyStep\CeremonyStepManagerFactory;
use Webauthn\PublicKeyCredential;

/**
 * Authenticate a user using a WebAuthn passkey.
 */
public function authenticatePasskey(Request $request)
{
    $validated = $request->validate([
        'answer' => ['required', 'json'],
    ]);

    // Deserialize the answer from the request
    $publicKeyCredential = JsonSerializer::deserialize($validated['answer'], PublicKeyCredential::class);

    if (! $publicKeyCredential->response instanceof AuthenticatorAssertionResponse) {
        return redirect()->guest(backpack_url('login'));
    }

    $passkey = Passkey::firstWhere('credential_id', $publicKeyCredential->rawId);

    if (! $passkey) {
        throw ValidationException::withMessages(['email' => 'The passkey is invalid.']);
    }

    try {
        $publicKeyCredentialSource = AuthenticatorAssertionResponseValidator::create(
            (new CeremonyStepManagerFactory)->requestCeremony()
        )->check(
            publicKeyCredentialSource: $passkey->data,
            authenticatorAssertionResponse: $publicKeyCredential->response,
            publicKeyCredentialRequestOptions: Session::get('passkey_authentication_options'),
            host: $request->getHost(),
            userHandle: null,
        );
    } catch (\Throwable $e) {
        throw ValidationException::withMessages([
            'email' => 'The passkey is invalid.',
        ]);
    }

    $passkey->update(['data' => $publicKeyCredentialSource]);

    // Login the user
    $this->guard()->loginUsingId($passkey->user_id);

    $request->session()->regenerate();

    return redirect()->intended($this->redirectPath());
}

and the final puzzle, the frontend method to send the assertion response.

Locate the section // Handle the actual authentication in the file resources/views/vendor/backpack/theme-tabler/auth/login/inc/form.blade.php

$passkeyButton.on('click', async () => {
      try {
          $passkeyButton.prop('disabled', true);
          $('body').css('cursor', 'wait');

          const authenticationOptions = {!! json_encode(\App\Support\JsonSerializer::serialize(session('passkey_authentication_options'))) !!};

          if (!authenticationOptions) {
              throw new Error('Authentication options not found');
          }

          // Start the authentication process
          const options = JSON.parse(authenticationOptions);
          const credential = await SimpleWebAuthnBrowser.startAuthentication(options);

          // Create and submit form with credential
          $('<form>', {
              method: 'POST',
              action: '{{ route('passkey.authenticate') }}',
              html: `
              <input type="hidden" name="_token" value="${$('meta[name="csrf-token"]').attr('content')}">
              <input type="hidden" name="answer" value='${JSON.stringify(credential)}'>
          `
          }).appendTo('body').submit();

      } catch (error) {
          console.error('Passkey authentication error:', error);
          $passkeyButton.prop('disabled', false);
          $('body').css('cursor', 'default');

          new Noty({
              type: 'error',
              text: 'Passkey authentication failed. Please try again.',
              timeout: 5000
          }).show();

          window.location.href = '{{ backpack_url() }}';
      }
  });

Bonus Step: Delete User Passkey

Let’s complete the passkey management functionality by making the delete button work. This is just a simple sweetalert code copied from delete operations in Backpack.

Add this function in your @section('after_scripts') in passkey.blade.php.

// Delete passkey function
function deleteEntry(button) {
    var route = $(button).attr('data-route');

    swal({
        title: "{!! trans('backpack::base.warning') !!}",
        text: "{!! trans('backpack::crud.delete_confirm') !!}",
        icon: "warning",
        buttons: {
            cancel: {
                text: "{!! trans('backpack::crud.cancel') !!}",
                value: null,
                visible: true,
                className: "bg-secondary",
                closeModal: true,
            },
            delete: {
                text: "{!! trans('backpack::crud.delete') !!}",
                value: true,
                visible: true,
                className: "bg-danger",
            }
        },
        dangerMode: true,
    }).then((value) => {
        if (value) {
            $.ajax({
                url: route,
                type: 'DELETE',
                headers: {
                    'X-CSRF-TOKEN': $('meta[name="csrf-token"]').attr('content')
                },
                success: function (result) {
                    if (result == 1) {
                        // Remove the row from the table
                        $(button).closest('tr').fadeOut(function () {
                            $(this).remove();

                            // If no more rows, show the "no passkeys" message
                            if ($('table tbody tr').length === 0) {
                                $('.table-responsive').replaceWith(
                                    '<div class="alert alert-info">You have no passkeys registered yet.</div>'
                                );
                            }
                        });

                        // Show a success notification
                        new Noty({
                            type: "success",
                            text: "{!! '<strong>'.trans('backpack::crud.delete_confirmation_title').'</strong><br>'.trans('backpack::crud.delete_confirmation_message') !!}"
                        }).show();
                    } else {
                        // Show an error alert
                        swal({
                            title: "{!! trans('backpack::crud.delete_confirmation_not_title') !!}",
                            text: "{!! trans('backpack::crud.delete_confirmation_not_message') !!}",
                            icon: "error",
                            timer: 4000,
                            buttons: false,
                        });
                    }
                },
                error: function (result) {
                    // Show an error alert
                    swal({
                        title: "{!! trans('backpack::crud.delete_confirmation_not_title') !!}",
                        text: "{!! trans('backpack::crud.delete_confirmation_not_message') !!}",
                        icon: "error",
                        timer: 4000,
                        buttons: false,
                    });
                }
            });
        }
    });
}

Don’t forget to attach it to the button.

<button type="button" class="btn btn-sm btn-danger"
        onclick="deleteEntry(this)"
        data-route="{{ route('backpack.passkey.delete', $passkey->id) }}">
    <i class="la la-trash"></i> Delete
</button>

Finally, create the corresponding route in routes/backpack/custom.php and the method in the backend controller.

Route::delete('passkey/{id}', [PasskeyController::class, 'destroy'])->name('backpack.passkey.delete');
public function destroy($id): string
{
    // For new passkey creation
    Session::keep('passkey_register_options');

    $user = $this->guard()->user();

    // Find the passkey and ensure it belongs to the current user
    $passkey = $user->passkeys()->find($id);

    if (! $passkey) {
        return '0';  // Return 0 when passkey missing
    }

    try {
        if ($passkey->delete()) {
            return '1';  // Return 1 for success
        }

        return '0';  // Return 0 for failure
    } catch (\Exception $e) {
        return '0';
    }
}

By now, you have added passkey authentication to your Laravel Backpack admin panel. I hope you enjoyed this tutorial. You can check the working source code on Github: kiddtang/backpack-passkey-auth.

Special thanks to Luke Downing and his excellent Laracasts course “Adding Passkeys to Your Laravel App” which served as a foundational inspiration for this tutorial.

Want to receive more articles like this?

Subscribe to our "Article Digest". We'll send you a list of the new articles, every week, month or quarter - your choice.

Reactions & Comments

What do you think about this?

Latest Articles

Wondering what our community has been up to?