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:
- Higher security: Cannot be phished or stolen
- Better UX: Just biometric, no password to remember
- Open standard: Works on all platforms supporting WebAuthn
- 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