Passkey ve WebAuthn'i production'a almak
Bir e-ticaret projesinde şifre sıfırlama isteklerinin destek taleplerinin %40'ını oluşturduğunu gördüğümde, passkey entegrasyonunu ciddiye almaya başladım. Şifre tabanlı authentication'ın hem kullanıcı hem geliştirici tarafında yarattığı sürtünme artık görmezden gelinemeyecek düzeyde. WebAuthn API, bu sorunu tarayıcı düzeyinde çözüyor; ama production'a taşımak, "navigator.credentials.create çağır" demek kadar basit değil.
WebAuthn akışının iki ayağı
WebAuthn protokolü iki temel ceremony'den oluşur: registration (kayıt) ve authentication (doğrulama). İkisi de challenge-response modeline dayanır. Sunucu rastgele bir challenge üretir, istemci bunu imzalar, sunucu imzayı doğrular.
Registration sırasında tarayıcı yeni bir anahtar çifti oluşturur. Public key sunucuya gider, private key cihazda (veya passkey sağlayıcıda) kalır. Authentication sırasında sunucu bir challenge gönderir, istemci private key ile imzalar, sunucu kayıtlı public key ile doğrular.
Bu iki adımı karıştırmamak gerekiyor. Registration'da navigator.credentials.create(), authentication'da navigator.credentials.get() kullanılır. İkisinin option nesneleri birbirine benzer ama farklı alanlara sahip.
Sunucu tarafı: challenge üretimi ve doğrulama
WebAuthn'in sunucu tarafı implementasyonunu sıfırdan yazmak hata yapmaya açık. @simplewebauthn/server (v13.x) paketi, FIDO2 spesifikasyonunun karmaşık kısımlarını soyutluyor. Node.js ortamında registration ve authentication ceremony'lerini şöyle kurabilirsiniz:
// server/webauthn.ts
import {
generateRegistrationOptions,
verifyRegistrationResponse,
generateAuthenticationOptions,
verifyAuthenticationResponse,
} from "@simplewebauthn/server";
import type {
RegistrationResponseJSON,
AuthenticationResponseJSON,
} from "@simplewebauthn/types";
const rpName = "Acme App";
const rpID = "acme.example.com";
const origin = `https://${rpID}`;
// Registration başlatma
export async function startRegistration(userId: string, userName: string) {
// Kullanıcının mevcut passkey'lerini DB'den çek
const existingCredentials = await getCredentialsByUserId(userId);
const options = await generateRegistrationOptions({
rpName,
rpID,
userID: new TextEncoder().encode(userId),
userName,
excludeCredentials: existingCredentials.map((c) => ({
id: c.credentialId,
transports: c.transports,
})),
authenticatorSelection: {
residentKey: "required",
userVerification: "preferred",
},
});
// Challenge'ı session'da sakla (5 dk TTL)
await saveChallenge(userId, options.challenge);
return options;
}
// Registration doğrulama
export async function finishRegistration(
userId: string,
response: RegistrationResponseJSON
) {
const expectedChallenge = await getChallenge(userId);
const verification = await verifyRegistrationResponse({
response,
expectedChallenge,
expectedOrigin: origin,
expectedRPID: rpID,
});
if (verification.verified && verification.registrationInfo) {
const { credential } = verification.registrationInfo;
await saveCredential(userId, {
credentialId: credential.id,
publicKey: credential.publicKey,
counter: credential.counter,
transports: response.response.transports ?? [],
});
}
return verification.verified;
}Burada dikkat edilmesi gereken birkaç nokta var. residentKey: "required" ayarı, passkey'in discoverable credential olmasını zorunlu kılar. Bu sayede kullanıcı adı yazmadan giriş yapılabilir. excludeCredentials ise aynı cihazda mükerrer kayıt oluşmasını engeller.
İstemci tarafı: tarayıcı API'si ile iletişim
İstemci tarafında @simplewebauthn/browser paketi, ArrayBuffer dönüşümlerini ve base64url encoding işlemlerini halleder:
// client/auth.ts
import {
startRegistration,
startAuthentication,
} from "@simplewebauthn/browser";
async function registerPasskey() {
const optionsRes = await fetch("/api/webauthn/register/start", {
method: "POST",
});
const options = await optionsRes.json();
const credential = await startRegistration({ optionsJSON: options });
const verifyRes = await fetch("/api/webauthn/register/finish", {
method: "POST",
headers: { "Content-Type": "application/json" },
body: JSON.stringify(credential),
});
return verifyRes.ok;
}
async function loginWithPasskey() {
const optionsRes = await fetch("/api/webauthn/login/start", {
method: "POST",
});
const options = await optionsRes.json();
const assertion = await startAuthentication({ optionsJSON: options });
const verifyRes = await fetch("/api/webauthn/login/finish", {
method: "POST",
headers: { "Content-Type": "application/json" },
body: JSON.stringify(assertion),
});
return verifyRes.ok;
}startRegistration ve startAuthentication çağrıları, kullanıcıya platform authenticator dialog'unu (Touch ID, Windows Hello, Android biometrik) gösterir. Kullanıcı onaylarsa credential nesnesi döner. Reddederse NotAllowedError fırlatılır; bunu yakalayıp kullanıcıya anlamlı bir mesaj göstermek gerekir.
Authentication ceremony'nin sunucu tarafı
Registration'ı gördük, authentication tarafı da benzer bir akışla ilerler:
// server/webauthn.ts (devam)
export async function startLogin() {
const options = await generateAuthenticationOptions({
rpID,
userVerification: "preferred",
// allowCredentials boş bırakılırsa discoverable credential kullanılır
});
// Challenge'ı session'a yaz
await saveChallengeForLogin(options.challenge);
return options;
}
export async function finishLogin(response: AuthenticationResponseJSON) {
const expectedChallenge = await getLoginChallenge();
// credentialId'den kullanıcıyı bul
const credential = await getCredentialById(response.id);
if (!credential) throw new Error("Credential not found");
const verification = await verifyAuthenticationResponse({
response,
expectedChallenge,
expectedOrigin: origin,
expectedRPID: rpID,
credential: {
id: credential.credentialId,
publicKey: credential.publicKey,
counter: credential.counter,
},
});
if (verification.verified) {
// Counter'ı güncelle (replay attack koruması)
await updateCredentialCounter(
credential.credentialId,
verification.authenticationInfo.newCounter
);
}
return {
verified: verification.verified,
userId: credential.userId,
};
}Counter güncellemesi atlanmamalı. Her başarılı authentication'da authenticator counter'ı artırır. Sunucu tarafında saklanan counter'dan küçük bir değer gelirse, klonlanmış bir authenticator olabilir. Bu durumda credential'ı devre dışı bırakıp kullanıcıyı uyarmak doğru davranış.
Kurtarma senaryoları
Passkey'lerin en sık sorulan sorusu bu: "Telefonumu kaybedersem ne olacak?" Cevap, passkey sağlayıcılarının senkronizasyon mekanizmasına bağlı. Apple Keychain, Google Password Manager ve 1Password gibi sağlayıcılar passkey'leri cihazlar arası senkronize eder. Yani iPhone'u kaybeden bir kullanıcı, yeni iPhone'unda iCloud'dan passkey'ini geri alır.
Ama bu yeterli değil. Production'da şu senaryolara hazır olmak gerekir:
Birincisi, kullanıcının birden fazla passkey kaydetmesine izin verin. Kayıt akışında "Başka bir cihaz ekle" seçeneği sunun. Veritabanında bir kullanıcıya birden fazla credential bağlanabilmeli.
İkincisi, passkey tek authentication yöntemi olmamalı, en azından geçiş döneminde. Şifre + passkey hibrit modeli, kullanıcıların kilitlenmesini önler. Passkey yoksa şifreyle giriş, şifreyle giriş sonrası passkey kayıt önerisi iyi bir akış.
Üçüncüsü, kurtarma kodu (recovery code) mekanizması bulundurun. Kullanıcı ilk passkey kaydında tek kullanımlık kurtarma kodları almalı. Bu kodlar bcrypt ile hash'lenip saklanır, kullanıcıya sadece bir kere gösterilir.
Dördüncüsü, hesap kurtarma için e-posta tabanlı magic link bir yedek kanal olarak çalışabilir. Ama magic link'in kendisi de phishing'e açık, dolayısıyla rate limiting ve IP bazlı anomali tespiti eklenmeli.
Tarayıcı desteği ve platform farkları
WebAuthn Level 2 spesifikasyonu tüm modern tarayıcılarda destekleniyor. caniuse.com verilerine göre (Haziran 2025 itibarıyla) Chrome 67+, Firefox 60+, Safari 13+, Edge 79+ WebAuthn'i destekler. Passkey (discoverable credential + conditional UI) desteği daha yeni: Chrome 108+, Safari 16+, Firefox 122+.
Conditional UI, <input autocomplete="username webauthn"> özelliğiyle çalışır. Kullanıcı username alanına tıkladığında, tarayıcı kayıtlı passkey'leri autocomplete önerisi olarak gösterir. Bu deneyimi eklemek için:
<input
type="text"
autocomplete="username webauthn"
placeholder="Kullanıcı adı"
/>import {
startAuthentication,
browserSupportsWebAuthnAutofill,
} from "@simplewebauthn/browser";
async function initConditionalUI() {
const supported = await browserSupportsWebAuthnAutofill();
if (!supported) return;
const optionsRes = await fetch("/api/webauthn/login/start");
const options = await optionsRes.json();
try {
const assertion = await startAuthentication({
optionsJSON: options,
useBrowserAutofill: true,
});
// assertion'ı sunucuya gönder
} catch (e) {
// Kullanıcı iptal etti veya desteklenmiyor
}
}browserSupportsWebAuthnAutofill() kontrolü yapılmadan conditional UI başlatmak Firefox'un eski sürümlerinde sessizce başarısız olur. Kontrol eklemek zorunlu.
Veritabanı şeması
Credential'ları saklamak için minimum şema şöyle görünür. Drizzle ORM ile PostgreSQL örneği:
// db/schema.ts
import { pgTable, text, integer, bytea, timestamp } from "drizzle-orm/pg-core";
export const passkeys = pgTable("passkeys", {
credentialId: text("credential_id").primaryKey(),
userId: text("user_id").notNull().references(() => users.id),
publicKey: bytea("public_key").notNull(),
counter: integer("counter").notNull().default(0),
transports: text("transports").array(),
deviceName: text("device_name"),
createdAt: timestamp("created_at").defaultNow(),
lastUsedAt: timestamp("last_used_at"),
});lastUsedAt alanı, kullanıcıya "bu passkey'i en son ne zaman kullandın" bilgisini göstermek ve uzun süredir kullanılmayan credential'ları tespit etmek için faydalı.
rpID ve origin doğrulaması
WebAuthn güvenliğinin temel taşı, Relying Party ID (rpID) ve origin doğrulamasıdır. rpID, domain adınızın kendisi veya bir üst domain'i olabilir. app.acme.com için rpID acme.com olarak ayarlanabilir; bu durumda dashboard.acme.com de aynı credential'ları kullanabilir.
Ama localhost ile geliştirme yaparken dikkat edilmesi gereken bir durum var. WebAuthn spesifikasyonu localhost'u özel olarak ele alır ve HTTPS gerektirmez. Production'da ise HTTPS zorunlu. Bu yüzden geliştirme ortamında rpID'yi localhost, origin'i http://localhost:3000 olarak ayarlayıp, production'da environment variable'lardan çekmek doğru yaklaşım.
Mevcut auth kütüphaneleriyle entegrasyon
Better Auth ve Auth.js v5 gibi kütüphaneler passkey desteğini plugin veya provider olarak sunuyor. Better Auth'un passkey plugin'i, yukarıda anlattığım tüm ceremony akışını kendi içinde barındırır. Sıfırdan yazmak yerine bu kütüphaneleri kullanmak, özellikle session yönetimi ve mevcut authentication altyapısıyla entegrasyon açısından daha az hata potansiyeli taşır.
Ancak kütüphane kullanıyor olsanız bile WebAuthn'in altında ne döndüğünü anlamak gerekiyor. Aksi halde kurtarma senaryolarında, conditional UI eklerken veya cross-origin durumlarında kütüphanenin soyutlamasının dışına çıkmak zorunda kalırsınız.
Phishing direnci neden gerçek
Şifrelerin en büyük zafiyeti, kullanıcının sahte bir siteye şifresini girebilmesidir. WebAuthn'de bu mümkün değil. Tarayıcı, credential'ın bağlı olduğu origin'i kontrol eder. acme.com için oluşturulmuş bir passkey, acme-login.evil.com üzerinde çalışmaz. Bu kontrol tarayıcı seviyesinde gerçekleşir, kullanıcının müdahalesi yoktur. Phishing sitesi navigator.credentials.get() çağrısı yapsa bile, tarayıcı rpID uyuşmazlığını tespit edip isteği reddeder.
Bu, OTP veya SMS doğrulamasının sağlayamadığı bir koruma katmanı. SIM swap saldırıları veya real-time phishing proxy'leri (evilginx gibi araçlar) WebAuthn karşısında işe yaramaz.
Ne zaman passkey-only'ye geçmeli
Passkey'i şifreye alternatif olarak sunmak bugün güvenli bir adım. Passkey-only (şifresiz) moda geçmek ise kullanıcı kitlesine bağlı. Kurumsal bir SaaS'ta, cihaz yönetimi politikası olan bir ortamda passkey-only çalışır. Geniş bir tüketici kitlesine hitap eden bir üründe, paylaşılan cihaz senaryoları (kütüphane bilgisayarı, aile tableti) yüzünden şifre seçeneğini tamamen kaldırmak henüz erken. Kullanıcılarınızın %80'i passkey kaydetmiş olsa bile, kalan %20 için bir fallback mekanizması gerekiyor. Passkey'i varsayılan yapın, teşvik edin, ama şifre kapısını kilitlemeden önce kurtarma altyapınızın sağlam olduğundan emin olun.