Şifresiz kimlik doğrulama etiketine sahip kayıtlar gösteriliyor. Tüm kayıtları göster
Şifresiz kimlik doğrulama etiketine sahip kayıtlar gösteriliyor. Tüm kayıtları göster

22 Eylül 2025 Pazartesi

Passkey (WebAuthn) ile Şifresiz Giriş: Modern Web Uygulamalarına Adım Adım Entegrasyon

Passkey nedir ve neden şimdi?

Son yıllarda şifre sızıntıları, kimlik avı saldırıları ve kullanıcı deneyimi sorunları; geliştiricileri “şifresiz giriş” modellerine yöneltti. Passkey, FIDO2 ve WebAuthn standartlarını temel alarak kullanıcıların cihazlarında biyometrik (Face ID, Touch ID, Windows Hello) veya donanımsal güvenlik anahtarıyla güvenli, hızlı ve kimlik avına dayanıklı oturum açmalarını sağlar. Chrome, Safari, Edge ve Firefox’un güncel sürümleri ile iOS, Android ve Windows ekosistemleri passkey’i yerel olarak destekliyor.

Mimariye hızlı bakış

Passkey, sunucu (Relying Party) ve tarayıcı/cihaz (Authenticator) arasında challenge temelli bir akış kullanır. Sunucu, kayıt veya giriş için benzersiz bir challenge üretir; istemci tarafında navigator.credentials.create() (kayıt) veya navigator.credentials.get() (giriş) çağrısı yapılır ve oluşan kriptografik yanıt sunucuya geri gönderilir. Sunucu, bu yanıtı doğrular ve oturum açılır.

Önkoşullar ve en iyi uygulamalar

- Üretimde HTTPS zorunludur (localhost geliştirmede istisna).
- RP ID alan adınız olmalı (örn. example.com). Alt alan adılarında doğrulama için RP ID’yi üst alan adına ayarlayın.
- Kullanıcı kimlikleri ve cihazdan dönen credential ID’leri veritabanında güvenle saklanmalı.
- Kimlik avına dayanıklılık için cross-origin isteklerden kaçının; Origin ve Relying Party ID tutarlı olmalı.

Adım adım: Basit bir Node.js/Express ve WebAuthn entegrasyonu

Örnek, sunucuda @simplewebauthn/server kütüphanesini kullanır. İstemci tarafında ise tarayıcı API’lerini çağırıyoruz. Bu iskelet, tek başına üretime hazır değildir; amaç kavramı göstermektir.

1) Sunucuyu hazırlayın

// server.js
import express from 'express';
import cors from 'cors';
import session from 'express-session';
import {
  generateRegistrationOptions,
  verifyRegistrationResponse,
  generateAuthenticationOptions,
  verifyAuthenticationResponse,
} from '@simplewebauthn/server';

const app = express();
app.use(cors({ origin: 'https://app.example.com', credentials: true }));
app.use(express.json());
app.use(session({ secret: 'change-me', resave: false, saveUninitialized: true }));

// Demo bellek içi depolar
const users = new Map(); // userId -> { id, username, credentials: [] }

const rpID = 'example.com';
const rpName = 'Örnek Uygulama';

// Kayıt: seçenek üret
app.post('/webauthn/register/options', (req, res) => {
  const { username, userId } = req.body;
  let user = users.get(userId);
  if (!user) {
    user = { id: userId, username, credentials: [] };
    users.set(userId, user);
  }
  const options = generateRegistrationOptions({
    rpName,
    rpID,
    userID: user.id,
    userName: user.username,
    attestationType: 'none',
    excludeCredentials: user.credentials.map(c => ({ id: c.id, type: 'public-key' })),
    authenticatorSelection: { residentKey: 'preferred', userVerification: 'preferred' },
  });
  req.session.currentChallenge = options.challenge;
  res.json(options);
});

// Kayıt: doğrula
app.post('/webauthn/register/verify', async (req, res) => {
  const { userId, attResp } = req.body;
  const user = users.get(userId);
  const verification = await verifyRegistrationResponse({
    response: attResp,
    expectedChallenge: req.session.currentChallenge,
    expectedOrigin: 'https://app.example.com',
    expectedRPID: rpID,
  });
  if (verification.verified) {
    const { credentialPublicKey, credentialID, counter } = verification.registrationInfo;
    user.credentials.push({ id: credentialID, publicKey: credentialPublicKey, counter });
  }
  res.json({ ok: verification.verified });
});

// Giriş: seçenek üret
app.post('/webauthn/login/options', (req, res) => {
  const { userId } = req.body;
  const user = users.get(userId);
  const options = generateAuthenticationOptions({
    rpID,
    userVerification: 'preferred',
    allowCredentials: user ? user.credentials.map(c => ({ id: c.id, type: 'public-key' })) : [],
  });
  req.session.currentChallenge = options.challenge;
  res.json(options);
});

// Giriş: doğrula
app.post('/webauthn/login/verify', async (req, res) => {
  const { userId, authResp } = req.body;
  const user = users.get(userId);
  const dbCred = user.credentials.find(c => Buffer.compare(c.id, Buffer.from(authResp.rawId, 'base64url')) === 0);
  const verification = await verifyAuthenticationResponse({
    response: authResp,
    expectedChallenge: req.session.currentChallenge,
    expectedOrigin: 'https://app.example.com',
    expectedRPID: rpID,
    authenticator: dbCred,
  });
  if (verification.verified) {
    dbCred.counter = verification.authenticationInfo.newCounter;
    // Burada oturumu işaretleyin (req.session.userId = userId gibi)
  }
  res.json({ ok: verification.verified });
});

app.listen(3000, () => console.log('Server listening on http://localhost:3000'));

2) İstemci tarafı: Kayıt ve giriş akışı

// register.js (tarayıcı)
async function startRegistration(username, userId) {
  const opts = await fetch('/webauthn/register/options', {
    method: 'POST',
    headers: {'Content-Type': 'application/json'},
    body: JSON.stringify({ username, userId })
  }).then(r => r.json());

  // ArrayBuffer alanlarını dönüştürün
  opts.challenge = base64urlToBuffer(opts.challenge);
  opts.user.id = new TextEncoder().encode(opts.user.id);

  const cred = await navigator.credentials.create({ publicKey: opts });
  const attResp = credentialToJSON(cred);
  const res = await fetch('/webauthn/register/verify', {
    method: 'POST',
    headers: {'Content-Type': 'application/json'},
    body: JSON.stringify({ userId, attResp })
  }).then(r => r.json());
  return res.ok;
}

async function startLogin(userId) {
  const opts = await fetch('/webauthn/login/options', {
    method: 'POST',
    headers: {'Content-Type': 'application/json'},
    body: JSON.stringify({ userId })
  }).then(r => r.json());

  opts.challenge = base64urlToBuffer(opts.challenge);
  opts.allowCredentials = (opts.allowCredentials || []).map(a => ({ ...a, id: base64urlToBuffer(a.id) }));

  const assertion = await navigator.credentials.get({ publicKey: opts });
  const authResp = credentialToJSON(assertion);
  const res = await fetch('/webauthn/login/verify', {
    method: 'POST',
    headers: {'Content-Type': 'application/json'},
    body: JSON.stringify({ userId, authResp })
  }).then(r => r.json());
  return res.ok;
}

// Yardımcılar
function base64urlToBuffer(b64url) {
  const pad = '='.repeat((4 - (b64url.length % 4)) % 4);
  const b64 = (b64url + pad).replace(/-/g, '+').replace(/_/g, '/');
  const raw = atob(b64);
  const buf = new ArrayBuffer(raw.length);
  const view = new Uint8Array(buf);
  for (let i = 0; i < raw.length; ++i) view[i] = raw.charCodeAt(i);
  return buf;
}

function credentialToJSON(cred) {
  return JSON.parse(JSON.stringify(cred, (k, v) => v instanceof ArrayBuffer
    ? bufferToBase64url(v)
    : v));
}

function bufferToBase64url(buf) {
  const bytes = new Uint8Array(buf);
  let binary = '';
  for (let i = 0; i < bytes.byteLength; i++) binary += String.fromCharCode(bytes[i]);
  return btoa(binary).replace(/\+/g, '-').replace(/\//g, '_').replace(/=+$/,'');
}

Güvenlik, UX ve üretim tavsiyeleri

- Kullanıcı adısız (usernameless) giriş için “resident key” tercih edin ve uygun akışı tasarlayın.
- Birden fazla passkey kaydına izin vererek cihaz kaybına karşı dayanıklılık sağlayın.
- Platform passkey (cihazda) ile çapraz platform passkey (iCloud Keychain, Google Password Manager) farklarını kullanıcılara açıklayın.
- Hata yönetimini özenle yapın: tarayıcıda NotAllowedError vb. hataları kullanıcı dostu mesajlara çevirin.
- Oturum yönetiminde kısa ömürlü JWT veya güvenli, HttpOnly, SameSite=strict cookie tercih edin.

Sorun giderme

- Origin ve RP ID uyuşmazlığı en sık hatadır: “https://app.example.com” ile rpID “example.com” olmalı.
- Sunucunuz ve istemci cihazınızın saat farkları challenge doğrulamasını bozabilir; NTP senkronizasyonunu sağlayın.
- Geliştirme aşamasında sadece localhost özel izni vardır; kendi alan adınızda test ederken mutlaka HTTPS kullanın.
- Kurumsal ortamlarda WebAuthn politikaları kısıtlanmış olabilir; tarayıcı ve OS politikalarını kontrol edin.

Sonuç

Passkey/WebAuthn, hem güvenlik hem de kullanıcı deneyimi tarafında anlamlı bir sıçrama sunuyor. Doğru RP ID, sağlam challenge yönetimi ve tutarlı origin politikasıyla kurulum basit hale geliyor. Üretime geçişte çoklu cihaz desteği, kullanıcı eğitimleri ve geri dönüş (fallback) stratejileri planlandığında, şifresiz geleceğe hazır bir kimlik doğrulama katmanı elde edebilirsiniz.

29 Ağustos 2025 Cuma

Next.js Uygulamalarında WebAuthn (Passkey) ile Şifresiz Giriş: Adım Adım Rehber

Parola sızıntıları, phishing ve tekrar kullanılan şifreler derken kimlik doğrulama artık güvenliğin en zayıf halkası olmaya başladı. Tam da bu noktada WebAuthn ve Passkey yaklaşımı devreye giriyor. Tarayıcılar ve işletim sistemleri, donanımsal anahtarlar veya biyometrik sensörler aracılığıyla şifresiz, kimlik doğrulama destekli bir deneyim sunuyor. Bu yazıda, Next.js tabanlı bir web uygulamasına WebAuthn (Passkey) entegrasyonunu adım adım, pratik ipuçlarıyla anlatıyorum.

WebAuthn nedir? Özetle, tarayıcınız üzerinde çalışan ve FIDO2 standardıyla uyumlu modern bir kimlik doğrulama API’sidir. Kullanıcı, cihazındaki güvenli öğeler (Secure Enclave, TPM veya güvenlik anahtarı) aracılığıyla bir anahtar çifti oluşturur. Sunucu tarafında yalnızca genel anahtar saklanır; özel anahtar cihazı terk etmez. Böylece phishing riski ciddi ölçüde azalır, parola çalma senaryoları boşa düşer ve kullanıcı deneyimi hızlanır.

Temel kavramlar: Relying Party (RP), uygulamanızın kimlik doğrulamayı kullanan tarafıdır. rpId genellikle alan adınızdır (ör. example.com). origin ise protokol ve alan adını içerir (ör. https://example.com). Bu iki değer, güvenlik için kritik ve tarayıcı doğrulamasının merkezinde yer alır.

Ön koşullar ve gereksinimler: WebAuthn yalnızca HTTPS üzerinde çalışır. Geliştirme ortamı için https://localhost istisnası geçerlidir. Sunucu tarafında FIDO2 doğrulamasını kolaylaştırmak için SimpleWebAuthn gibi bir kütüphane kullanabilirsiniz. Next.js API Routes veya Route Handlers ile kayıt (registration) ve giriş (authentication) uç noktalarını ayırın. Veritabanında kullanıcıya ait credentialId, publicKey, signCount, transports gibi alanları saklayacağınız bir tablo/collection hazır bulunsun.

Kayıt (Registration) akışı nasıl işler? 1) Kullanıcı oturum açmadan önce ya da profil ekranından “Passkey ekle”yi seçer. 2) Sunucu bir challenge üretir ve istemciye PublicKeyCredentialCreationOptions ile gönderir: rp.name, rp.id, user.id, user.name, challenge, pubKeyAlg, attestation vb. 3) Tarayıcıda navigator.credentials.create() çağrılır; cihaz parmak izi/yüz tanıma ya da PIN ile onay ister. 4) Tarayıcıdan dönen attestation/clientDataJSON verileri sunucuya POST edilir. 5) Sunucu, veriyi doğrular; eşleşen rpId, origin, challenge ve imza kontrol edilir. 6) Doğrulama geçerse credentialId ve publicKey veritabanına eklenir. Artık kullanıcı, parolasız girişe hazırdır.

Giriş (Authentication) akışı nasıl işler? 1) Sunucu yeni bir challenge üretir ve istemciye PublicKeyCredentialRequestOptions ile gönderir (allowCredentials ile belirli credentialId’leri belirtebilir ya da disoverable credentials (resident keys) için boş bırakabilirsiniz). 2) Tarayıcıda navigator.credentials.get() çağrılır; kullanıcı cihazında biyometrik doğrulama ile imzalama yapılır. 3) İmza, clientDataJSON ve authenticatorData sunucuya geri gönderilir. 4) Sunucu, saklanan publicKey ile imzayı doğrular; signCount (anti-replay) kontrolünü günceller. 5) Başarılıysa güvenli bir oturum veya token üretir ve kullanıcıyı içeri alır.

rpId ve origin ayarları: rpId, üst düzey alan adınız olmalı (subdomain kullanıyorsanız, rpId genellikle kök alan adıdır). origin ise kesin ve tam eşleşmelidir (ör. https://app.example.com). Geliştirmede localhost kullanıyorsanız origin: https://localhost:3000 gibi olmalı; http yerine https zorunludur. Yanlış rpId ya da origin, en sık karşılaşılan hata nedenidir.

Güvenlik ve UX önerileri: 1) userVerification değerini “required” olarak ayarlayın; böylece biyometrik/PIN doğrulaması garanti altına alınır. 2) attestation genellikle “none” seçilir; gizliliği korur ve gereksiz sertifika karmaşıklığını azaltır. 3) Discoverable credentials (resident keys) ile kullanıcı adı girmeden giriş mümkündür; modern passkey deneyimi için önerilir. 4) Platform (cihaz içi) ve cross-platform (USB/NFC/Bluetooth anahtarlar) seçiminde kullanıcıya rehberlik edin. 5) iCloud Anahtar Zinciri ve Google Password Manager ile multi-device passkey senkronizasyonu sayesinde cihaz değişimlerinde erişim kolaylaşır. 6) Fallback akışı olarak e-posta sihirli linki veya destek süreci tasarlayın; fakat SMS OTP’yi temel kurtarma yolu yapmaktan kaçının.

Çerez ve CORS nüansları: Oturum için çerez kullanıyorsanız, üçüncü taraf senaryolarda SameSite=None; Secure ayarlarına dikkat edin. CSRF token korumasını atlamayın. API uç noktalarında yalnızca gerekli origin’lere izin verin ve yanıtlarınızda Cache-Control: no-store gibi başlıklarla hassas verilerin önbelleğe düşmesini engelleyin.

Sık yapılan hatalar: 1) rpId/origin eşleşmiyor: Tarayıcı reddeder. 2) Base64url kodlama/çözme yanlış: Özellikle credentialId ve ham bayt verilerinde hatalara neden olur. 3) Saat farklılıkları (clock skew): Token ve imza doğrulamaları etkilenebilir. 4) signCount güncellenmiyor: Anti-replay koruması devre dışı kalır. 5) Geliştirmede HTTP kullanma: WebAuthn çalışmaz, her zaman HTTPS.

Performans ve izleme: Kimlik doğrulama isteklerini ayrıntılı log’layın (challenge ID, rpId, origin, tarayıcı, platform). Başarısızlık oranlarını, cihaz türlerini ve geri dönüş sürelerini izlemek sorunları hızla yakalamanızı sağlar. Kullanıcı akışında gereksiz adımları azaltın; “Passkey ile giriş yap” butonunu görünür ve ilk seçenek haline getirin, parolayı ise ikincil yol olarak sunun.

Sonuç: WebAuthn ve Passkey entegrasyonu, hem güvenlik çıtasını yükseltir hem de kullanıcı deneyimini modernize eder. Next.js üzerinde birkaç uç nokta, doğru rpId/origin ayarı ve basit bir istemci çağrısıyla dakikalar içinde çalışan bir şifresiz giriş deneyimi elde edebilirsiniz. Başlangıçta kavramlar yoğun görünse de pratikte süreç net: challenge üret, tarayıcıda kimlik doğrula, imzayı doğrula, oturumu başlat. Bugün başlarsanız, yarın kullanıcılarınız parolasız bir geleceğe geçmiş olur.