Laravel'de Passkey Kimlik Dogrulama
Sifrelerin sonu geliyor mu bilmiyorum ama alternatifler giderek daha guclu hale geliyor. Passkey teknolojisi 2024'ten beri ciddi ivme kazandi, 2026'da artik neredeyse her cihaz destekliyor. Bu yazida Laravel projenize passkey destegi eklemeyi anlatiyorum.
Passkey Nedir?
Passkey, geleneksel sifre yerine kriptografik anahtar cifti kullanan bir kimlik dogrulama yontemi. FIDO2/WebAuthn standartlarina dayanir.
Nasil Calisiyor?
- Kayit: Tarayici bir public/private key cifti olusturur
- Public key sunucuya gonderilir ve kaydedilir
- Private key cihazda kalir (Secure Enclave, TPM vb.)
- Giris: Sunucu bir challenge gonderir, cihaz private key ile imzalar, sunucu public key ile dogrular
Sifre sunucu tarafinda saklanmiyor, phishing mumkun degil, brute force anlamsiz.
Neden 2026'da Passkey?
- Apple, Google ve Microsoft tam destek sagliyor
- iOS 16+, Android 9+, Windows 10+, macOS Ventura+ destekli
- Chrome, Safari, Firefox, Edge hepsi destekliyor
- AB dijital kimlik regülasyonu passkey'leri tesvik ediyor
- Buyuk servisler (Google, GitHub, Microsoft) varsayilan olarak sunuyor
spatie/laravel-passkeys Paketi
Spatie, Laravel icin guzel bir passkey paketi sunuyor. Kurulumu ve kullanimi basit.
Kurulum
composer require spatie/laravel-passkeys
php artisan vendor:publish --tag="passkeys-migrations"
php artisan migrate
Bu migration passkeys tablosunu olusturur:
Schema::create('passkeys', function (Blueprint $table) {
$table->id();
$table->morphs('authenticatable');
$table->text('credential_id');
$table->text('public_key');
$table->string('name')->nullable();
$table->timestamp('last_used_at')->nullable();
$table->timestamps();
});
Konfigurasyon
php artisan vendor:publish --tag="passkeys-config"
// config/passkeys.php
return [
'relying_party' => [
'name' => env('APP_NAME', 'Laravel'),
'id' => env('PASSKEY_RP_ID', null), // null = current domain
],
'authenticatable_model' => App\Models\User::class,
// Passkey ile giris yapildiginda
'redirect_to' => '/dashboard',
// Kullanici adi alani
'username' => 'email',
// Timeout (milisaniye)
'timeout' => 60000,
// Attestation tipi
'attestation' => 'none', // 'none', 'indirect', 'direct'
// Kullanici dogrulama
'user_verification' => 'preferred', // 'required', 'preferred', 'discouraged'
];
Model Hazirligi
use Spatie\LaravelPasskeys\Models\Concerns\HasPasskeys;
class User extends Authenticatable
{
use HasFactory, Notifiable, HasPasskeys;
}
Route'lari Kaydet
// routes/web.php
use Spatie\LaravelPasskeys\Http\Controllers\PasskeyLoginController;
use Spatie\LaravelPasskeys\Http\Controllers\PasskeyRegistrationController;
// Passkey kayit
Route::get('/passkeys/register/options', [PasskeyRegistrationController::class, 'options'])
->middleware('auth')
->name('passkeys.register.options');
Route::post('/passkeys/register', [PasskeyRegistrationController::class, 'store'])
->middleware('auth')
->name('passkeys.register.store');
// Passkey ile giris
Route::get('/passkeys/login/options', [PasskeyLoginController::class, 'options'])
->name('passkeys.login.options');
Route::post('/passkeys/login', [PasskeyLoginController::class, 'login'])
->name('passkeys.login.store');
Frontend Implementasyonu
Passkey islemleri WebAuthn API'si ile tarayicida gerceklesir. Livewire veya vanilla JS ile yapabilirsin.
Vanilla JavaScript ile Passkey Kayit
// passkey-register.js
async function registerPasskey() {
try {
// 1. Sunucudan registration options al
const optionsResponse = await fetch('/passkeys/register/options', {
headers: {
'Accept': 'application/json',
'X-Requested-With': 'XMLHttpRequest',
},
});
const options = await optionsResponse.json();
// 2. ArrayBuffer'a cevir
options.challenge = base64UrlToBuffer(options.challenge);
options.user.id = base64UrlToBuffer(options.user.id);
if (options.excludeCredentials) {
options.excludeCredentials = options.excludeCredentials.map(cred => ({
...cred,
id: base64UrlToBuffer(cred.id),
}));
}
// 3. Tarayicidan credential olustur (biyometrik onay burada olur)
const credential = await navigator.credentials.create({
publicKey: options,
});
// 4. Sunucuya gonder
const response = await fetch('/passkeys/register', {
method: 'POST',
headers: {
'Content-Type': 'application/json',
'Accept': 'application/json',
'X-CSRF-TOKEN': document.querySelector('meta[name="csrf-token"]').content,
},
body: JSON.stringify({
id: credential.id,
rawId: bufferToBase64Url(credential.rawId),
type: credential.type,
response: {
attestationObject: bufferToBase64Url(credential.response.attestationObject),
clientDataJSON: bufferToBase64Url(credential.response.clientDataJSON),
},
name: 'MacBook Pro', // Passkey ismi
}),
});
if (response.ok) {
alert('Passkey basariyla kaydedildi!');
}
} catch (error) {
console.error('Passkey kayit hatasi:', error);
}
}
// Yardimci fonksiyonlar
function base64UrlToBuffer(base64url) {
const base64 = base64url.replace(/-/g, '+').replace(/_/g, '/');
const binary = atob(base64);
const buffer = new ArrayBuffer(binary.length);
const view = new Uint8Array(buffer);
for (let i = 0; i < binary.length; i++) {
view[i] = binary.charCodeAt(i);
}
return buffer;
}
function bufferToBase64Url(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, '');
}
Vanilla JavaScript ile Passkey Giris
// passkey-login.js
async function loginWithPasskey() {
try {
// 1. Sunucudan authentication options al
const optionsResponse = await fetch('/passkeys/login/options', {
headers: { 'Accept': 'application/json' },
});
const options = await optionsResponse.json();
// 2. Challenge'i ArrayBuffer'a cevir
options.challenge = base64UrlToBuffer(options.challenge);
if (options.allowCredentials) {
options.allowCredentials = options.allowCredentials.map(cred => ({
...cred,
id: base64UrlToBuffer(cred.id),
}));
}
// 3. Tarayicidan credential al (biyometrik onay)
const assertion = await navigator.credentials.get({
publicKey: options,
});
// 4. Sunucuya gonder
const response = await fetch('/passkeys/login', {
method: 'POST',
headers: {
'Content-Type': 'application/json',
'X-CSRF-TOKEN': document.querySelector('meta[name="csrf-token"]').content,
},
body: JSON.stringify({
id: assertion.id,
rawId: bufferToBase64Url(assertion.rawId),
type: assertion.type,
response: {
authenticatorData: bufferToBase64Url(assertion.response.authenticatorData),
clientDataJSON: bufferToBase64Url(assertion.response.clientDataJSON),
signature: bufferToBase64Url(assertion.response.signature),
userHandle: assertion.response.userHandle
? bufferToBase64Url(assertion.response.userHandle)
: null,
},
}),
});
if (response.ok) {
window.location.href = '/dashboard';
}
} catch (error) {
if (error.name === 'NotAllowedError') {
alert('Islem iptal edildi veya zaman asimina ugradi.');
} else {
console.error('Passkey giris hatasi:', error);
}
}
}
Livewire Komponenti
// app/Livewire/PasskeyManager.php
use Livewire\Component;
class PasskeyManager extends Component
{
public function getPasskeys()
{
return auth()->user()->passkeys()->latest()->get();
}
public function deletePasskey(int $passkeyId)
{
$passkey = auth()->user()->passkeys()->findOrFail($passkeyId);
$passkey->delete();
$this->dispatch('passkey-deleted');
}
public function render()
{
return view('livewire.passkey-manager', [
'passkeys' => $this->getPasskeys(),
]);
}
}
<!-- resources/views/livewire/passkey-manager.blade.php -->
<div>
<h3>Kayitli Passkey'ler</h3>
@forelse($passkeys as $passkey)
<div class="flex items-center justify-between p-4 border rounded mb-2">
<div>
<p class="font-medium">{{ $passkey->name ?? 'Isimsiz Passkey' }}</p>
<p class="text-sm text-gray-500">
Son kullanim: {{ $passkey->last_used_at?->diffForHumans() ?? 'Hic kullanilmadi' }}
</p>
<p class="text-xs text-gray-400">
Olusturulma: {{ $passkey->created_at->format('d.m.Y H:i') }}
</p>
</div>
<button
wire:click="deletePasskey({{ $passkey->id }})"
wire:confirm="Bu passkey'i silmek istediginize emin misiniz?"
class="text-red-600 hover:text-red-800"
>
Sil
</button>
</div>
@empty
<p class="text-gray-500">Henuz kayitli passkey yok.</p>
@endforelse
<button
onclick="registerPasskey()"
class="mt-4 px-4 py-2 bg-blue-600 text-white rounded hover:bg-blue-700"
>
Yeni Passkey Ekle
</button>
</div>
Login Sayfasi
<!-- resources/views/auth/login.blade.php -->
<form method="POST" action="/login">
@csrf
<input type="email" name="email" placeholder="E-posta" required>
<input type="password" name="password" placeholder="Sifre" required>
<button type="submit">Giris Yap</button>
</form>
<div class="mt-4">
<p class="text-center text-gray-500">veya</p>
<button
onclick="loginWithPasskey()"
class="w-full mt-2 px-4 py-2 border rounded flex items-center justify-center gap-2"
id="passkey-login-btn"
>
<svg xmlns="http://www.w3.org/2000/svg" width="20" height="20" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2">
<path d="M2 18v3c0 .6.4 1 1 1h4v-3h3v-3h2l1.4-1.4a6.5 6.5 0 1 0-4-4Z"/>
<circle cx="16.5" cy="7.5" r=".5"/>
</svg>
Passkey ile Giris Yap
</button>
</div>
<script>
// Passkey destegini kontrol et
if (!window.PublicKeyCredential) {
document.getElementById('passkey-login-btn').style.display = 'none';
}
</script>
Onemli Hususlar
1. HTTPS Zorunlu
WebAuthn sadece guvenli baglam (secure context) altinda calisiyor. Yani HTTPS zorunlu. Tek istisna localhost.
# Production'da
APP_URL=https://your-app.com
2. Fallback Authentication
Passkey'i tek giris yontemi yapma. Kullanicilarin hepsinin passkey destekleyen cihazi olmayabilir.
// Sifre + Passkey birlikte sunulabilir
// Kullanici tercih ettigi yontemle giris yapar
3. Hesap Kurtarma
Kullanici tum cihazlarini kaybederse ne olacak? Mutlaka bir kurtarma mekanizmasi ol:
// Kurtarma kodu olustur (kayit sirasinda)
class RecoveryCodeService
{
public function generate(User $user): array
{
$codes = [];
for ($i = 0; $i < 8; $i++) {
$codes[] = Str::random(5) . '-' . Str::random(5);
}
$user->recovery_codes = encrypt(json_encode($codes));
$user->save();
return $codes; // Kullaniciya bir kez goster
}
public function verify(User $user, string $code): bool
{
$codes = json_decode(decrypt($user->recovery_codes), true);
if (in_array($code, $codes)) {
// Kullanilmis kodu cikar
$codes = array_diff($codes, [$code]);
$user->recovery_codes = encrypt(json_encode(array_values($codes)));
$user->save();
return true;
}
return false;
}
}
4. Coklu Cihaz Destegi
Bir kullanicinin birden fazla passkey'i olabilir (telefon, laptop, tablet). Her cihaz icin ayri passkey kaydettir.
// Kullanicinin kac passkey'i var?
$count = $user->passkeys()->count();
// Kayitli cihazlar
$devices = $user->passkeys()
->select('id', 'name', 'last_used_at', 'created_at')
->get();
5. Conditional UI (Autofill)
Modern tarayicilar passkey'leri sifre otomatik doldurma gibi onerebilir:
<input
type="text"
name="email"
autocomplete="username webauthn"
placeholder="E-posta"
>
// Conditional mediation ile passkey onerisi
async function conditionalLogin() {
if (!window.PublicKeyCredential ||
!PublicKeyCredential.isConditionalMediationAvailable) {
return;
}
const available = await PublicKeyCredential.isConditionalMediationAvailable();
if (!available) return;
try {
const options = await fetch('/passkeys/login/options').then(r => r.json());
options.challenge = base64UrlToBuffer(options.challenge);
const credential = await navigator.credentials.get({
publicKey: options,
mediation: 'conditional', // Bu satir onemli
});
// credential'i sunucuya gonder...
} catch (e) {
// Kullanici iptal ettiyse sessizce gec
}
}
// Sayfa yuklendiginde calistir
conditionalLogin();
6. Passkey Destegi Kontrolu
async function isPasskeySupported() {
// WebAuthn API mevcut mu?
if (!window.PublicKeyCredential) {
return false;
}
// Platform authenticator (Touch ID, Face ID, Windows Hello) var mi?
try {
const available = await PublicKeyCredential.isUserVerifyingPlatformAuthenticatorAvailable();
return available;
} catch {
return false;
}
}
// Kullanim
const supported = await isPasskeySupported();
if (supported) {
// Passkey kayit/giris butonlarini goster
} else {
// Klasik sifre formu goster
}
Test
// tests/Feature/PasskeyTest.php
use Tests\TestCase;
class PasskeyTest extends TestCase
{
public function test_passkey_registration_options_require_auth()
{
$response = $this->getJson('/passkeys/register/options');
$response->assertUnauthorized();
}
public function test_authenticated_user_can_get_registration_options()
{
$user = User::factory()->create();
$response = $this->actingAs($user)
->getJson('/passkeys/register/options');
$response->assertOk();
$response->assertJsonStructure([
'challenge',
'rp' => ['name', 'id'],
'user' => ['id', 'name', 'displayName'],
'pubKeyCredParams',
'timeout',
'attestation',
]);
}
public function test_user_can_delete_own_passkey()
{
$user = User::factory()->create();
$passkey = $user->passkeys()->create([
'credential_id' => 'test-credential',
'public_key' => 'test-key',
'name' => 'Test Device',
]);
$this->actingAs($user)
->delete("/passkeys/{$passkey->id}")
->assertOk();
$this->assertDatabaseMissing('passkeys', ['id' => $passkey->id]);
}
}
Sonuc
Passkey'ler sifrelerden daha guvenli, daha kullanici dostu ve phishing'e karsi dayanikli. 2026'da Laravel projenize passkey destegi eklemek artik zorunluluk degil ama guclu bir diferansiyasyon. Kullanicilariniz "sifresiz giris yap" butonunu gordugunde memnun olacaklar.
Onemli olan passkey'i tek yontem olarak dayatmamak. Sifre + passkey birlikte sunulabilir, kullanici hangisini isterse onu kullanir. Zaman icinde passkey kullanan kullanicilarin orani artacaktir.