Laravel Passkeys: Passwordless Authentication with WebAuthn

· 8 min read

Introduction

Passkeys are the next generation of authentication, replacing traditional passwords with cryptographic keys. Based on the WebAuthn (Web Authentication API) standard, passkeys provide higher security and better user experience.

Why Passkeys?

Password Problems Passkeys Solution
Easy to phish Completely phishing-resistant
Can be brute forced Nothing to guess
Users reuse them Unique per site
Easy to forget Stored on device
Can be stolen from DB Only public key stored

How Passkeys Work

Registration:
┌────────────┐     ┌────────────┐     ┌────────────┐
│   Browser  │────▶│   Server   │────▶│   Server   │
│            │     │  (Challenge)│     │(Store Key) │
└────────────┘     └────────────┘     └────────────┘
      │                                      ▲
      ▼                                      │
┌────────────┐                               │
│Authenticator│─────────────────────────────┘
│(Biometric)  │  (Create keypair, send public key)
└────────────┘

Authentication:
┌────────────┐     ┌────────────┐     ┌────────────┐
│   Browser  │────▶│   Server   │────▶│   Server   │
│            │     │  (Challenge)│     │ (Verify)   │
└────────────┘     └────────────┘     └────────────┘
      │                                      ▲
      ▼                                      │
┌────────────┐                               │
│Authenticator│─────────────────────────────┘
│(Biometric)  │  (Sign challenge with private key)
└────────────┘

Package Installation

Using webauthn-framework

composer require web-auth/webauthn-lib
composer require web-auth/webauthn-symfony-bundle

Or using laragear/webauthn

composer require laragear/webauthn

php artisan vendor:publish --provider="Laragear\WebAuthn\WebAuthnServiceProvider"

php artisan migrate

Database Schema

Migration

// database/migrations/2026_05_04_create_webauthn_credentials_table.php
<?php

use Illuminate\Database\Migrations\Migration;
use Illuminate\Database\Schema\Blueprint;
use Illuminate\Support\Facades\Schema;

return new class extends Migration
{
    public function up(): void
    {
        Schema::create('webauthn_credentials', function (Blueprint $table) {
            $table->string('id', 255)->primary();
            $table->foreignId('user_id')->constrained()->cascadeOnDelete();
            $table->string('name')->nullable();
            $table->string('type', 32)->default('public-key');
            $table->json('transports')->nullable();
            $table->string('attestation_type')->default('none');
            $table->json('trust_path')->nullable();
            $table->uuid('aaguid');
            $table->binary('public_key');
            $table->unsignedInteger('counter')->default(0);
            $table->json('other_ui')->nullable();
            $table->timestamp('last_used_at')->nullable();
            $table->timestamps();
            
            $table->index('user_id');
        });
    }

    public function down(): void
    {
        Schema::dropIfExists('webauthn_credentials');
    }
};

Model Setup

User Model

// app/Models/User.php
<?php

namespace App\Models;

use App\Models\WebAuthnCredential;
use Illuminate\Foundation\Auth\User as Authenticatable;
use Illuminate\Notifications\Notifiable;

class User extends Authenticatable
{
    use Notifiable;
    
    protected $fillable = [
        'name',
        'email',
        'password',
    ];
    
    protected $hidden = [
        'password',
        'remember_token',
    ];
    
    protected function casts(): array
    {
        return [
            'email_verified_at' => 'datetime',
            'password' => 'hashed',
        ];
    }
    
    public function webauthnCredentials()
    {
        return $this->hasMany(WebAuthnCredential::class);
    }
    
    public function hasPasskeys(): bool
    {
        return $this->webauthnCredentials()->exists();
    }
}

WebAuthn Credential Model

// app/Models/WebAuthnCredential.php
<?php

namespace App\Models;

use Illuminate\Database\Eloquent\Model;
use Illuminate\Database\Eloquent\Relations\BelongsTo;

class WebAuthnCredential extends Model
{
    protected $primaryKey = 'id';
    
    public $incrementing = false;
    
    protected $keyType = 'string';
    
    protected $fillable = [
        'id',
        'user_id',
        'name',
        'type',
        'transports',
        'attestation_type',
        'trust_path',
        'aaguid',
        'public_key',
        'counter',
        'other_ui',
        'last_used_at',
    ];
    
    protected function casts(): array
    {
        return [
            'transports' => 'array',
            'trust_path' => 'array',
            'other_ui' => 'array',
            'last_used_at' => 'datetime',
        ];
    }
    
    public function user(): BelongsTo
    {
        return $this->belongsTo(User::class);
    }
    
    public function updateCounter(int $counter): void
    {
        $this->update([
            'counter' => $counter,
            'last_used_at' => now(),
        ]);
    }
}

WebAuthn Service

// app/Services/WebAuthnService.php
<?php

namespace App\Services;

use App\Models\User;
use App\Models\WebAuthnCredential;
use Webauthn\AuthenticatorSelectionCriteria;
use Webauthn\PublicKeyCredentialCreationOptions;
use Webauthn\PublicKeyCredentialRequestOptions;
use Webauthn\PublicKeyCredentialRpEntity;
use Webauthn\PublicKeyCredentialUserEntity;

class WebAuthnService
{
    private PublicKeyCredentialRpEntity $rpEntity;
    
    public function __construct()
    {
        $this->rpEntity = new PublicKeyCredentialRpEntity(
            name: config('app.name'),
            id: parse_url(config('app.url'), PHP_URL_HOST),
        );
    }
    
    public function generateRegistrationOptions(User $user): PublicKeyCredentialCreationOptions
    {
        $userEntity = new PublicKeyCredentialUserEntity(
            name: $user->email,
            id: (string) $user->id,
            displayName: $user->name,
        );
        
        $excludeCredentials = $user->webauthnCredentials
            ->map(fn ($cred) => [
                'type' => 'public-key',
                'id' => base64_decode($cred->id),
            ])
            ->toArray();
        
        $authenticatorSelection = new AuthenticatorSelectionCriteria(
            authenticatorAttachment: AuthenticatorSelectionCriteria::AUTHENTICATOR_ATTACHMENT_PLATFORM,
            residentKey: AuthenticatorSelectionCriteria::RESIDENT_KEY_REQUIREMENT_PREFERRED,
            userVerification: AuthenticatorSelectionCriteria::USER_VERIFICATION_REQUIREMENT_REQUIRED,
        );
        
        $options = new PublicKeyCredentialCreationOptions(
            rp: $this->rpEntity,
            user: $userEntity,
            challenge: random_bytes(32),
            pubKeyCredParams: $this->getSupportedAlgorithms(),
            authenticatorSelection: $authenticatorSelection,
            timeout: 60000,
            excludeCredentials: $excludeCredentials,
            attestation: PublicKeyCredentialCreationOptions::ATTESTATION_CONVEYANCE_PREFERENCE_NONE,
        );
        
        session(['webauthn_registration_options' => $options]);
        
        return $options;
    }
    
    public function generateAuthenticationOptions(?User $user = null): PublicKeyCredentialRequestOptions
    {
        $allowCredentials = [];
        
        if ($user) {
            $allowCredentials = $user->webauthnCredentials
                ->map(fn ($cred) => [
                    'type' => 'public-key',
                    'id' => base64_decode($cred->id),
                    'transports' => $cred->transports ?? [],
                ])
                ->toArray();
        }
        
        $options = new PublicKeyCredentialRequestOptions(
            challenge: random_bytes(32),
            timeout: 60000,
            rpId: $this->rpEntity->getId(),
            allowCredentials: $allowCredentials,
            userVerification: PublicKeyCredentialRequestOptions::USER_VERIFICATION_REQUIREMENT_REQUIRED,
        );
        
        session(['webauthn_authentication_options' => $options]);
        
        return $options;
    }
    
    private function getSupportedAlgorithms(): array
    {
        return [
            ['type' => 'public-key', 'alg' => -7],   // ES256
            ['type' => 'public-key', 'alg' => -257], // RS256
        ];
    }
}

Controllers

Passkey Registration Controller

// app/Http/Controllers/PasskeyController.php
<?php

namespace App\Http\Controllers;

use App\Services\WebAuthnService;
use Illuminate\Http\JsonResponse;
use Illuminate\Http\Request;

class PasskeyController extends Controller
{
    public function __construct(
        private WebAuthnService $webAuthn
    ) {}
    
    public function registerOptions(Request $request): JsonResponse
    {
        $options = $this->webAuthn->generateRegistrationOptions($request->user());
        
        return response()->json([
            'publicKey' => [
                'challenge' => $this->base64UrlEncode($options->getChallenge()),
                'rp' => [
                    'name' => $options->getRp()->getName(),
                    'id' => $options->getRp()->getId(),
                ],
                'user' => [
                    'id' => $this->base64UrlEncode($options->getUser()->getId()),
                    'name' => $options->getUser()->getName(),
                    'displayName' => $options->getUser()->getDisplayName(),
                ],
                'pubKeyCredParams' => $options->getPubKeyCredParams(),
                'timeout' => $options->getTimeout(),
                'attestation' => $options->getAttestation(),
            ],
        ]);
    }
    
    public function register(Request $request): JsonResponse
    {
        $request->validate([
            'id' => 'required|string',
            'rawId' => 'required|string',
            'type' => 'required|string|in:public-key',
            'response.clientDataJSON' => 'required|string',
            'response.attestationObject' => 'required|string',
            'name' => 'nullable|string|max:255',
        ]);
        
        // Verification logic here...
        
        return response()->json(['success' => true]);
    }
    
    public function index(Request $request): JsonResponse
    {
        $credentials = $request->user()
            ->webauthnCredentials()
            ->orderBy('created_at', 'desc')
            ->get(['id', 'name', 'created_at', 'last_used_at']);
        
        return response()->json(['credentials' => $credentials]);
    }
    
    public function destroy(Request $request, string $id): JsonResponse
    {
        $request->user()
            ->webauthnCredentials()
            ->findOrFail($id)
            ->delete();
        
        return response()->json(['success' => true]);
    }
    
    private function base64UrlEncode(string $data): string
    {
        return rtrim(strtr(base64_encode($data), '+/', '-_'), '=');
    }
}

Routes

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

// Public routes for passkey login
Route::prefix('passkey')->group(function () {
    Route::post('login/options', [PasskeyLoginController::class, 'options']);
    Route::post('login', [PasskeyLoginController::class, 'authenticate']);
});

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

Frontend Implementation

JavaScript Helper

// resources/js/passkey.js
class PasskeyManager {
    constructor() {
        this.supported = this.checkSupport();
    }
    
    checkSupport() {
        return window.PublicKeyCredential !== undefined &&
               typeof window.PublicKeyCredential === 'function';
    }
    
    async checkPlatformAuthenticator() {
        if (!this.supported) return false;
        return await PublicKeyCredential.isUserVerifyingPlatformAuthenticatorAvailable();
    }
    
    base64UrlDecode(base64url) {
        const base64 = base64url.replace(/-/g, '+').replace(/_/g, '/');
        const padding = '='.repeat((4 - base64.length % 4) % 4);
        const binary = atob(base64 + padding);
        return Uint8Array.from(binary, c => c.charCodeAt(0));
    }
    
    base64UrlEncode(buffer) {
        const bytes = new Uint8Array(buffer);
        let binary = '';
        for (let i = 0; i < bytes.length; i++) {
            binary += String.fromCharCode(bytes[i]);
        }
        return btoa(binary).replace(/\+/g, '-').replace(/\//g, '_').replace(/=/g, '');
    }
    
    async register(name = null) {
        // Get options from server
        const optionsResponse = await fetch('/passkey/register/options', {
            method: 'POST',
            headers: {
                'Content-Type': 'application/json',
                'X-CSRF-TOKEN': document.querySelector('meta[name="csrf-token"]').content,
            },
        });
        
        const { publicKey } = await optionsResponse.json();
        
        // Convert base64url to ArrayBuffer
        publicKey.challenge = this.base64UrlDecode(publicKey.challenge);
        publicKey.user.id = this.base64UrlDecode(publicKey.user.id);
        
        // Create credential
        const credential = await navigator.credentials.create({ publicKey });
        
        // Send to server
        const response = await fetch('/passkey/register', {
            method: 'POST',
            headers: {
                'Content-Type': 'application/json',
                'X-CSRF-TOKEN': document.querySelector('meta[name="csrf-token"]').content,
            },
            body: JSON.stringify({
                id: credential.id,
                rawId: this.base64UrlEncode(credential.rawId),
                type: credential.type,
                response: {
                    clientDataJSON: this.base64UrlEncode(credential.response.clientDataJSON),
                    attestationObject: this.base64UrlEncode(credential.response.attestationObject),
                },
                name: name,
            }),
        });
        
        return await response.json();
    }
    
    async authenticate(email = null) {
        // Get options from server
        const optionsResponse = await fetch('/passkey/login/options', {
            method: 'POST',
            headers: {
                'Content-Type': 'application/json',
                'X-CSRF-TOKEN': document.querySelector('meta[name="csrf-token"]').content,
            },
            body: JSON.stringify({ email }),
        });
        
        const { publicKey } = await optionsResponse.json();
        
        // Convert base64url to ArrayBuffer
        publicKey.challenge = this.base64UrlDecode(publicKey.challenge);
        
        if (publicKey.allowCredentials) {
            publicKey.allowCredentials = publicKey.allowCredentials.map(cred => ({
                ...cred,
                id: this.base64UrlDecode(cred.id),
            }));
        }
        
        // Get credential
        const credential = await navigator.credentials.get({ publicKey });
        
        // Send to server
        const response = await fetch('/passkey/login', {
            method: 'POST',
            headers: {
                'Content-Type': 'application/json',
                'X-CSRF-TOKEN': document.querySelector('meta[name="csrf-token"]').content,
            },
            body: JSON.stringify({
                id: credential.id,
                rawId: this.base64UrlEncode(credential.rawId),
                type: credential.type,
                response: {
                    clientDataJSON: this.base64UrlEncode(credential.response.clientDataJSON),
                    authenticatorData: this.base64UrlEncode(credential.response.authenticatorData),
                    signature: this.base64UrlEncode(credential.response.signature),
                    userHandle: credential.response.userHandle 
                        ? this.base64UrlEncode(credential.response.userHandle)
                        : null,
                },
            }),
        });
        
        return await response.json();
    }
}

window.PasskeyManager = PasskeyManager;

Security Considerations

1. Challenge Validation

// Always validate challenge from session
if (!hash_equals($storedChallenge, $receivedChallenge)) {
    throw new SecurityException('Challenge mismatch');
}

2. Origin Validation

// Verify origin matches your domain
$clientData = json_decode($clientDataJSON, true);
$origin = $clientData['origin'];

if ($origin !== config('app.url')) {
    throw new SecurityException('Invalid origin');
}

3. Counter Validation

// Prevent replay attacks
if ($newCounter <= $credential->counter) {
    throw new SecurityException('Possible cloned authenticator detected');
}

Conclusion

Passkeys provide:

  1. Higher security: Cannot be phished or stolen
  2. Better UX: Just biometric, no password to remember
  3. Open standard: Works on all platforms supporting WebAuthn
  4. Easy implementation: Libraries available for Laravel

Implementation Checklist

  • Database migration for credentials
  • WebAuthn service implementation
  • Registration flow (options + verify)
  • Authentication flow (options + verify)
  • Frontend JavaScript helper
  • UI for login and settings
  • Counter validation for replay protection
  • Fallback for unsupported browsers

References

Comments