İçeriğe geç
·25 dk okuma

2026'da Modern Kimlik Doğrulama: JWT, Session, OAuth ve Passkey

Tam kimlik doğrulama manzarası: session vs JWT ne zaman kullanılır, OAuth 2.0/OIDC akışları, refresh token rotasyonu, passkey'ler (WebAuthn) ve Next.js'te gerçekten kullandığım auth kalıpları.

Paylaş:X / TwitterLinkedIn

Kimlik doğrulama, web geliştirmenin "çalışıyor"un asla yeterli olmadığı tek alanıdır. Tarih seçicindeki bir hata sinir bozucudur. Auth sistemindeki bir hata ise veri ihlalidir.

Sıfırdan kimlik doğrulama implemente ettim, sağlayıcılar arası geçiş yaptım, token hırsızlığı olaylarını debug ettim ve "güvenliği sonra düzeltiriz" kararlarının sonuçlarıyla uğraştım. Bu yazı, başlarken sahip olmak istediğim kapsamlı rehberdir. Sadece teori değil — gerçek ödünleşimler, gerçek güvenlik açıkları ve production baskısı altında ayakta kalan kalıplar.

Session'lar, JWT'ler, OAuth 2.0, passkey'ler, MFA ve yetkilendirme konularının tamamını ele alacağız. Sonunda yalnızca her mekanizmanın nasıl çalıştığını değil, ne zaman kullanılacağını ve alternatiflerin neden var olduğunu da anlayacaksınız.

Session vs JWT: Gerçek Ödünleşimler#

Bu, karşılaşacağınız ilk karardır ve internet bu konuda kötü tavsiyelerle doludur. Gerçekten neyin önemli olduğunu ortaya koyayım.

Session Tabanlı Kimlik Doğrulama#

Session'lar orijinal yaklaşımdır. Sunucu bir session kaydı oluşturur, bunu bir yerde saklar (veritabanı, Redis, bellek) ve istemciye bir çerez içinde opak bir session ID verir.

typescript
// Basitleştirilmiş session oluşturma
import { randomBytes } from "crypto";
import { cookies } from "next/headers";
 
interface Session {
  userId: string;
  createdAt: Date;
  expiresAt: Date;
  ipAddress: string;
  userAgent: string;
}
 
async function createSession(userId: string, request: Request): Promise<string> {
  const sessionId = randomBytes(32).toString("hex");
 
  const session: Session = {
    userId,
    createdAt: new Date(),
    expiresAt: new Date(Date.now() + 24 * 60 * 60 * 1000), // 24 saat
    ipAddress: request.headers.get("x-forwarded-for") ?? "unknown",
    userAgent: request.headers.get("user-agent") ?? "unknown",
  };
 
  // Veritabanınızda veya Redis'te saklayın
  await redis.set(`session:${sessionId}`, JSON.stringify(session), "EX", 86400);
 
  const cookieStore = await cookies();
  cookieStore.set("session_id", sessionId, {
    httpOnly: true,
    secure: true,
    sameSite: "lax",
    maxAge: 86400,
    path: "/",
  });
 
  return sessionId;
}

Avantajları gerçektir:

  • Anında iptal. Session kaydını silin ve kullanıcı çıkış yapmış olur. Süre dolmasını beklemeye gerek yok. Bu, şüpheli aktivite tespit ettiğinizde önemlidir.
  • Session görünürlüğü. Kullanıcılara aktif session'larını gösterebilirsiniz ("Chrome'da, Windows 11'de, İstanbul'da oturum açtınız") ve tek tek iptal etmelerine izin verebilirsiniz.
  • Küçük çerez boyutu. Session ID genellikle 64 karakterdir. Çerez asla büyümez.
  • Sunucu taraflı kontrol. Session verilerini güncelleyebilirsiniz (bir kullanıcıyı admin'e yükseltme, izinleri değiştirme) ve bu bir sonraki istekte geçerli olur.

Dezavantajları da gerçektir:

  • Her istekte veritabanı sorgusu. Her kimliği doğrulanmış istek bir session araması gerektirir. Redis ile bu milisaniyenin altındadır, ama yine de bir bağımlılıktır.
  • Yatay ölçekleme paylaşımlı depolama gerektirir. Birden fazla sunucunuz varsa, hepsinin aynı session deposuna erişmesi gerekir. Yapışkan session'lar kırılgan bir geçici çözümdür.
  • CSRF bir endişedir. Çerezler otomatik olarak gönderildiği için CSRF korumasına ihtiyacınız vardır. SameSite çerezler bunu büyük ölçüde çözer, ama nedenini anlamanız gerekir.

JWT Tabanlı Kimlik Doğrulama#

JWT'ler modeli tersine çevirir. Session durumunu sunucuda saklamak yerine, bunu istemcinin tuttuğu imzalı bir token'a kodlarsınız.

typescript
import { SignJWT, jwtVerify } from "jose";
 
const secret = new TextEncoder().encode(process.env.JWT_SECRET);
 
async function createAccessToken(userId: string, role: string): Promise<string> {
  return new SignJWT({ sub: userId, role })
    .setProtectedHeader({ alg: "HS256" })
    .setIssuedAt()
    .setExpirationTime("15m")
    .setIssuer("https://akousa.net")
    .setAudience("https://akousa.net")
    .sign(secret);
}
 
async function verifyAccessToken(token: string) {
  try {
    const { payload } = await jwtVerify(token, secret, {
      issuer: "https://akousa.net",
      audience: "https://akousa.net",
    });
    return payload;
  } catch {
    return null;
  }
}

Avantajları:

  • Sunucu taraflı depolama yok. Token bağımsızdır. İmzayı doğrular ve claim'leri okursunuz. Veritabanı sorgusu yok.
  • Servisler arası çalışır. Mikroservis mimarisinde, genel anahtara sahip herhangi bir servis token'ı doğrulayabilir. Paylaşımlı session deposu gerekmez.
  • Durumsuz ölçekleme. Session yakınlığı konusunda endişelenmeden daha fazla sunucu ekleyebilirsiniz.

Dezavantajları — ve bunlar insanların üzerinden geçtiği şeylerdir:

  • Bir JWT'yi iptal edemezsiniz. Bir kez verildiğinde, süresi dolana kadar geçerlidir. Bir kullanıcının hesabı ele geçirilirse, zorla çıkış yaptıramazsınız. Bir engelleme listesi oluşturabilirsiniz, ama o zaman sunucu taraflı durumu yeniden eklemiş ve ana avantajı kaybetmiş olursunuz.
  • Token boyutu. Birkaç claim içeren JWT'ler genellikle 800+ bayttır. Roller, izinler ve meta veriler ekleyin ve her istekte kilobaytlar gönderiyorsunuzdur.
  • Payload okunabilir. Payload Base64 ile kodlanmıştır, şifrelenmemiştir. Herkes çözebilir. Asla hassas verileri bir JWT'ye koymayın.
  • Saat kayması sorunları. Sunucularınızın farklı saatleri varsa (olur), süre dolma kontrolleri güvenilmez hale gelir.

Her Birini Ne Zaman Kullanmalı#

Temel kuralım:

Şu durumlarda session kullanın: Monolitik bir uygulamanız var, anında iptal ihtiyacınız var, hesap güvenliğinin kritik olduğu tüketici odaklı bir ürün yapıyorsunuz veya auth gereksinimleriniz sık değişebilir.

Şu durumlarda JWT kullanın: Servislerin kimliği bağımsız olarak doğrulaması gereken bir mikroservis mimariniz var, API'den API'ye iletişim yapıyorsunuz veya üçüncü taraf bir kimlik doğrulama sistemi implemente ediyorsunuz.

Pratikte: Çoğu uygulama session kullanmalıdır. "JWT'ler daha ölçeklenebilir" argümanı yalnızca session depolamanın çözemediği gerçek bir ölçekleme sorununuz varsa geçerlidir — ve Redis saniyede milyonlarca session araması yapar. Daha modern göründükleri için JWT seçen, sonra session'lardan daha karmaşık bir engelleme listesi ve yenileme token sistemi inşa eden çok fazla proje gördüm.

JWT Derinlemesine İnceleme#

Session tabanlı auth seçseniz bile, OAuth, OIDC ve üçüncü taraf entegrasyonları aracılığıyla JWT'lerle karşılaşacaksınız. İç yapıyı anlamak tartışmasızdır.

Bir JWT'nin Anatomisi#

Bir JWT, noktalarla ayrılmış üç bölümden oluşur: header.payload.signature

eyJhbGciOiJSUzI1NiIsInR5cCI6IkpXVCJ9.
eyJzdWIiOiJ1c2VyXzEyMyIsInJvbGUiOiJhZG1pbiIsImlhdCI6MTcwOTMxMjAwMCwiZXhwIjoxNzA5MzEyOTAwfQ.
kQ8s7nR2xC...

Header — algoritmayı ve token tipini bildirir:

json
{
  "alg": "RS256",
  "typ": "JWT"
}

Payload — claim'leri içerir. Standart claim'lerin kısa isimleri vardır:

json
{
  "sub": "user_123",       // Subject (bu kimin hakkında)
  "iss": "https://auth.example.com",  // Issuer (bunu kim oluşturdu)
  "aud": "https://api.example.com",   // Audience (bunu kim kabul etmeli)
  "iat": 1709312000,       // Issued At (Unix zaman damgası)
  "exp": 1709312900,       // Expiration (Unix zaman damgası)
  "role": "admin"          // Özel claim
}

Signature — token'ın kurcalanmadığını kanıtlar. Kodlanmış header ve payload'ın gizli bir anahtarla imzalanmasıyla oluşturulur.

RS256 vs HS256: Bu Gerçekten Önemli#

HS256 (HMAC-SHA256) — simetrik. Aynı gizli anahtar imzalar ve doğrular. Basit, ama token'ları doğrulaması gereken her servisin gizli anahtara sahip olması gerekir. Herhangi biri ele geçirilirse, saldırgan token'lar sahteleyebilir.

RS256 (RSA-SHA256) — asimetrik. Özel anahtar imzalar, genel anahtar doğrular. Yalnızca auth sunucusu özel anahtara ihtiyaç duyar. Herhangi bir servis genel anahtarla doğrulayabilir. Bir doğrulama servisi ele geçirilirse, saldırgan token'ları okuyabilir ama sahteleyemez.

typescript
import { SignJWT, jwtVerify, importPKCS8, importSPKI } from "jose";
 
// RS256 — birden fazla servis token doğruladığında bunu kullanın
const privateKeyPem = process.env.JWT_PRIVATE_KEY!;
const publicKeyPem = process.env.JWT_PUBLIC_KEY!;
 
async function signWithRS256(payload: Record<string, unknown>) {
  const privateKey = await importPKCS8(privateKeyPem, "RS256");
 
  return new SignJWT(payload)
    .setProtectedHeader({ alg: "RS256", typ: "JWT" })
    .setIssuedAt()
    .setExpirationTime("15m")
    .sign(privateKey);
}
 
async function verifyWithRS256(token: string) {
  const publicKey = await importSPKI(publicKeyPem, "RS256");
 
  const { payload } = await jwtVerify(token, publicKey, {
    algorithms: ["RS256"], // KRİTİK: algoritmaları her zaman kısıtlayın
  });
 
  return payload;
}

Kural: Token'lar servis sınırlarını aştığında RS256 kullanın. HS256'yı yalnızca aynı servis hem imzalayıp hem doğruladığında kullanın.

alg: none Saldırısı#

Bu, en ünlü JWT güvenlik açığıdır ve utanç verici derecede basittir. Bazı JWT kütüphaneleri eskiden şunları yapıyordu:

  1. Header'dan alg alanını oku
  2. Belirtilen algoritmayı kullan
  3. alg: "none" ise, imza doğrulamayı tamamen atla

Bir saldırgan geçerli bir JWT alabilir, payload'ı değiştirebilir (örneğin, "role": "admin" ayarlayabilir), alg"none" olarak ayarlayabilir, imzayı kaldırabilir ve gönderebilir. Sunucu bunu kabul ederdi.

typescript
// GÜVENLİK AÇIĞI — bunu asla yapmayın
function verifyJwt(token: string) {
  const [headerB64, payloadB64, signature] = token.split(".");
  const header = JSON.parse(atob(headerB64));
 
  if (header.alg === "none") {
    // "İmza gerekmiyor" — FELAKET
    return JSON.parse(atob(payloadB64));
  }
 
  // ... imzayı doğrula
}

Çözüm basittir: beklenen algoritmayı her zaman açıkça belirtin. Token'ın size nasıl doğrulayacağınızı söylemesine asla izin vermeyin.

typescript
// GÜVENLİ — algoritma sabit kodlanmış, token'dan okunmuyor
const { payload } = await jwtVerify(token, key, {
  algorithms: ["RS256"], // Yalnızca RS256 kabul et — header'ı yok say
});

jose gibi modern kütüphaneler bunu varsayılan olarak doğru bir şekilde ele alır, ama yine de derinlemesine savunma olarak algorithms seçeneğini açıkça geçirmelisiniz.

Algoritma Karışıklığı Saldırısı#

Yukarıdakiyle ilişkili: Bir sunucu RS256 kabul edecek şekilde yapılandırılmışsa, bir saldırgan:

  1. Sunucunun genel anahtarını alabilir (sonuçta herkese açık)
  2. alg: "HS256" ile bir token oluşturabilir
  3. Genel anahtarı HMAC gizli anahtarı olarak kullanarak imzalayabilir

Sunucu alg header'ını okuyup HS256 doğrulamaya geçerse, genel anahtar (herkesin bildiği) paylaşılan gizli anahtar haline gelir. İmza geçerlidir. Saldırgan bir token sahtelemiştir.

Yine, çözüm aynıdır: token header'ından algoritmaya asla güvenmeyin. Her zaman sabit kodlayın.

Refresh Token Rotasyonu#

JWT kullanıyorsanız, bir refresh token stratejisine ihtiyacınız var. Uzun ömürlü bir access token göndermek sorun istemektir — çalınırsa, saldırgan tüm ömür boyunca erişime sahip olur.

Kalıp şudur:

  • Access token: kısa ömürlü (15 dakika). API istekleri için kullanılır.
  • Refresh token: uzun ömürlü (30 gün). Yalnızca yeni bir access token almak için kullanılır.
typescript
import { randomBytes } from "crypto";
 
interface RefreshTokenRecord {
  tokenHash: string;
  userId: string;
  familyId: string;  // İlişkili token'ları gruplar
  used: boolean;
  expiresAt: Date;
  createdAt: Date;
}
 
async function issueTokenPair(userId: string) {
  const familyId = randomBytes(16).toString("hex");
 
  const accessToken = await createAccessToken(userId);
  const refreshToken = randomBytes(64).toString("hex");
  const refreshTokenHash = await hashToken(refreshToken);
 
  // Refresh token kaydını sakla
  await db.refreshToken.create({
    data: {
      tokenHash: refreshTokenHash,
      userId,
      familyId,
      used: false,
      expiresAt: new Date(Date.now() + 30 * 24 * 60 * 60 * 1000),
      createdAt: new Date(),
    },
  });
 
  return { accessToken, refreshToken };
}

Her Kullanımda Rotasyon#

İstemci yeni bir access token almak için her refresh token kullandığında, yeni bir refresh token verirsiniz ve eskisini geçersiz kılarsınız:

typescript
async function rotateTokens(incomingRefreshToken: string) {
  const tokenHash = await hashToken(incomingRefreshToken);
  const record = await db.refreshToken.findUnique({
    where: { tokenHash },
  });
 
  if (!record) {
    // Token mevcut değil — olası hırsızlık
    return null;
  }
 
  if (record.expiresAt < new Date()) {
    // Token'ın süresi dolmuş
    await db.refreshToken.delete({ where: { tokenHash } });
    return null;
  }
 
  if (record.used) {
    // BU TOKEN ZATEN KULLANILDI.
    // Birisi tekrar oynatıyor — ya meşru kullanıcı
    // ya da saldırgan. Her iki durumda da tüm aileyi öldür.
    await db.refreshToken.deleteMany({
      where: { familyId: record.familyId },
    });
 
    console.error(
      `Kullanıcı ${record.userId}, aile ${record.familyId} için refresh token yeniden kullanımı tespit edildi. Ailedeki tüm token'lar geçersiz kılındı.`
    );
 
    return null;
  }
 
  // Mevcut token'ı kullanılmış olarak işaretle (silme — yeniden kullanım tespiti için gerekli)
  await db.refreshToken.update({
    where: { tokenHash },
    data: { used: true },
  });
 
  // Aynı aile ID'si ile yeni çift ver
  const newRefreshToken = randomBytes(64).toString("hex");
  const newRefreshTokenHash = await hashToken(newRefreshToken);
 
  await db.refreshToken.create({
    data: {
      tokenHash: newRefreshTokenHash,
      userId: record.userId,
      familyId: record.familyId,  // Aynı aile
      used: false,
      expiresAt: new Date(Date.now() + 30 * 24 * 60 * 60 * 1000),
      createdAt: new Date(),
    },
  });
 
  const newAccessToken = await createAccessToken(record.userId);
 
  return { accessToken: newAccessToken, refreshToken: newRefreshToken };
}

Aile Geçersiz Kılma Neden Önemli#

Şu senaryoyu düşünün:

  1. Kullanıcı giriş yapar, refresh token A'yı alır
  2. Saldırgan refresh token A'yı çalar
  3. Saldırgan A'yı kullanarak yeni bir çift alır (access token + refresh token B)
  4. Kullanıcı elindeki A'yı kullanarak yenilemeye çalışır

Yeniden kullanım tespiti olmadan, kullanıcı sadece bir hata alır. Saldırgan B token'ı ile devam eder. Kullanıcı tekrar giriş yapar, hesabının ele geçirildiğini asla bilmez.

Yeniden kullanım tespiti ve aile geçersiz kılma ile: kullanıcı zaten kullanılmış A token'ını kullanmaya çalıştığında, sistem yeniden kullanımı tespit eder, ailedeki her token'ı (B dahil) geçersiz kılar ve hem kullanıcıyı hem de saldırganı yeniden kimlik doğrulamaya zorlar. Kullanıcı bir "lütfen tekrar giriş yapın" istemi alır ve bir şeylerin yanlış olduğunu fark edebilir.

Bu, Auth0, Okta ve Auth.js tarafından kullanılan yaklaşımdır. Mükemmel değildir — saldırgan token'ı meşru kullanıcıdan önce kullanırsa, yeniden kullanım uyarısını tetikleyen meşru kullanıcı olur. Ama taşıyıcı token'larla yapabileceğimizin en iyisi budur.

OAuth 2.0 ve OIDC#

OAuth 2.0 ve OpenID Connect, "Google/GitHub/Apple ile Giriş Yap"ın arkasındaki protokollerdir. Bir kütüphane kullansanız bile bunları anlamak zorunludur, çünkü işler bozulduğunda — ve bozulacaktır — protokol seviyesinde neler olduğunu bilmeniz gerekir.

Temel Ayrım#

OAuth 2.0 bir yetkilendirme protokolüdür. Şu soruyu yanıtlar: "Bu uygulama bu kullanıcının verilerine erişebilir mi?" Sonuç, belirli izinler (scope'lar) veren bir access token'dır.

OpenID Connect (OIDC), OAuth 2.0'ın üzerine inşa edilmiş bir kimlik doğrulama katmanıdır. Şu soruyu yanıtlar: "Bu kullanıcı kim?" Sonuç, kullanıcı kimlik bilgilerini içeren bir ID token'dır (bir JWT).

"Google ile Giriş Yap" dediğinizde, OIDC kullanıyorsunuz. Google uygulamanıza kullanıcının kim olduğunu söyler (kimlik doğrulama). Ayrıca takvimlerine veya drive'larına erişmek için OAuth scope'ları da isteyebilirsiniz (yetkilendirme).

PKCE ile Authorization Code Akışı#

Bu, web uygulamaları için kullanmanız gereken akıştır. PKCE (Proof Key for Code Exchange) başlangıçta mobil uygulamalar için tasarlandı, ancak artık sunucu taraflı uygulamalar dahil tüm istemciler için önerilmektedir.

typescript
import { randomBytes, createHash } from "crypto";
 
// Adım 1: PKCE değerlerini oluşturun ve kullanıcıyı yönlendirin
function initiateOAuthFlow() {
  // Code verifier: rastgele 43-128 karakter dizesi
  const codeVerifier = randomBytes(32)
    .toString("base64url")
    .slice(0, 43);
 
  // Code challenge: verifier'ın SHA256 hash'i, base64url-kodlanmış
  const codeChallenge = createHash("sha256")
    .update(codeVerifier)
    .digest("base64url");
 
  // State: CSRF koruması için rastgele değer
  const state = randomBytes(16).toString("hex");
 
  // Yönlendirmeden önce her ikisini de session'da saklayın (sunucu taraflı!)
  // code_verifier'ı ASLA bir çereze veya URL parametresine koymayın
  session.codeVerifier = codeVerifier;
  session.oauthState = state;
 
  const authUrl = new URL("https://accounts.google.com/o/oauth2/v2/auth");
  authUrl.searchParams.set("client_id", process.env.GOOGLE_CLIENT_ID!);
  authUrl.searchParams.set("redirect_uri", "https://example.com/api/auth/callback/google");
  authUrl.searchParams.set("response_type", "code");
  authUrl.searchParams.set("scope", "openid email profile");
  authUrl.searchParams.set("state", state);
  authUrl.searchParams.set("code_challenge", codeChallenge);
  authUrl.searchParams.set("code_challenge_method", "S256");
 
  return authUrl.toString();
}
typescript
// Adım 2: Callback'i işleyin
async function handleOAuthCallback(request: Request) {
  const url = new URL(request.url);
  const code = url.searchParams.get("code");
  const state = url.searchParams.get("state");
  const error = url.searchParams.get("error");
 
  // Sağlayıcıdan hata kontrolü
  if (error) {
    throw new Error(`OAuth hatası: ${error}`);
  }
 
  // State'in eşleştiğini doğrulayın (CSRF koruması)
  if (state !== session.oauthState) {
    throw new Error("State uyuşmazlığı — olası CSRF saldırısı");
  }
 
  // Authorization code'u token'larla değiştirin
  const tokenResponse = await fetch("https://oauth2.googleapis.com/token", {
    method: "POST",
    headers: { "Content-Type": "application/x-www-form-urlencoded" },
    body: new URLSearchParams({
      grant_type: "authorization_code",
      code: code!,
      redirect_uri: "https://example.com/api/auth/callback/google",
      client_id: process.env.GOOGLE_CLIENT_ID!,
      client_secret: process.env.GOOGLE_CLIENT_SECRET!,
      code_verifier: session.codeVerifier, // PKCE: bu akışı bizim başlattığımızı kanıtlar
    }),
  });
 
  const tokens = await tokenResponse.json();
  // tokens.access_token — Google'a API çağrıları için
  // tokens.id_token — kullanıcı kimliği içeren JWT (OIDC)
  // tokens.refresh_token — yeni access token'lar almak için
 
  // Adım 3: ID token'ı doğrulayın ve kullanıcı bilgilerini çıkarın
  const idTokenPayload = await verifyGoogleIdToken(tokens.id_token);
 
  return {
    googleId: idTokenPayload.sub,
    email: idTokenPayload.email,
    name: idTokenPayload.name,
    picture: idTokenPayload.picture,
  };
}

Üç Endpoint#

Her OAuth/OIDC sağlayıcısı bunları sunar:

  1. Authorization endpoint — kullanıcıyı giriş yapması ve izin vermesi için yönlendirdiğiniz yer. Bir authorization code döner.
  2. Token endpoint — sunucunuzun authorization code'u access/refresh/ID token'larla değiştirdiği yer. Bu, sunucudan sunucuya bir çağrıdır.
  3. UserInfo endpoint — access token kullanarak ek kullanıcı profil verilerini getirebileceğiniz yer. OIDC ile bunun büyük kısmı zaten ID token'da mevcuttur.

State Parametresi#

state parametresi, OAuth callback'ine yönelik CSRF saldırılarını önler. Onsuz:

  1. Saldırgan kendi makinesinde bir OAuth akışı başlatır, bir authorization code alır
  2. Saldırgan bir URL hazırlar: https://sizinapp.com/callback?code=SALDIRGAN_KODU
  3. Saldırgan kurbanı tıklamaya ikna eder (e-posta bağlantısı, gizli resim)
  4. Uygulamanız saldırganın kodunu değiştirir ve saldırganın Google hesabını kurbanın session'ına bağlar

state ile: uygulamanız rastgele bir değer üretir, session'da saklar ve authorization URL'ye dahil eder. Callback geldiğinde state'in eşleştiğini doğrularsınız. Saldırgan bunu sahteleyemez çünkü kurbanın session'ına erişimi yoktur.

Auth.js (NextAuth) ile Next.js App Router#

Auth.js, çoğu Next.js projesinde ilk başvurduğum şeydir. OAuth dansını, session yönetimini, veritabanı kalıcılığını ve CSRF korumasını ele alır. İşte production'a hazır bir kurulum.

Temel Yapılandırma#

typescript
// src/lib/auth.ts
import NextAuth from "next-auth";
import Google from "next-auth/providers/google";
import GitHub from "next-auth/providers/github";
import Credentials from "next-auth/providers/credentials";
import { PrismaAdapter } from "@auth/prisma-adapter";
import { prisma } from "@/lib/prisma";
import { verifyPassword } from "@/lib/password";
 
export const { handlers, auth, signIn, signOut } = NextAuth({
  adapter: PrismaAdapter(prisma),
 
  // Daha iyi güvenlik için veritabanı session'ları kullanın (JWT değil)
  session: {
    strategy: "database",
    maxAge: 30 * 24 * 60 * 60, // 30 gün
    updateAge: 24 * 60 * 60,   // Her 24 saatte session'ı uzat
  },
 
  providers: [
    Google({
      clientId: process.env.GOOGLE_CLIENT_ID!,
      clientSecret: process.env.GOOGLE_CLIENT_SECRET!,
      // Belirli scope'lar isteyin
      authorization: {
        params: {
          scope: "openid email profile",
          prompt: "consent",
          access_type: "offline", // Refresh token al
        },
      },
    }),
 
    GitHub({
      clientId: process.env.GITHUB_CLIENT_ID!,
      clientSecret: process.env.GITHUB_CLIENT_SECRET!,
    }),
 
    // E-posta/şifre girişi (dikkatli kullanın)
    Credentials({
      credentials: {
        email: { label: "E-posta", type: "email" },
        password: { label: "Şifre", type: "password" },
      },
      authorize: async (credentials) => {
        if (!credentials?.email || !credentials?.password) {
          return null;
        }
 
        const user = await prisma.user.findUnique({
          where: { email: credentials.email as string },
        });
 
        if (!user || !user.passwordHash) {
          return null;
        }
 
        const isValid = await verifyPassword(
          credentials.password as string,
          user.passwordHash
        );
 
        if (!isValid) {
          return null;
        }
 
        return {
          id: user.id,
          email: user.email,
          name: user.name,
          image: user.image,
        };
      },
    }),
  ],
 
  callbacks: {
    // Kimin giriş yapabileceğini kontrol edin
    async signIn({ user, account }) {
      // Yasaklı kullanıcıların girişini engelleyin
      if (user.id) {
        const dbUser = await prisma.user.findUnique({
          where: { id: user.id },
          select: { banned: true },
        });
        if (dbUser?.banned) return false;
      }
      return true;
    },
 
    // Session'a özel alanlar ekleyin
    async session({ session, user }) {
      if (session.user) {
        session.user.id = user.id;
        // Veritabanından rol getir
        const dbUser = await prisma.user.findUnique({
          where: { id: user.id },
          select: { role: true },
        });
        session.user.role = dbUser?.role ?? "user";
      }
      return session;
    },
  },
 
  pages: {
    signIn: "/login",
    error: "/auth/error",
    verifyRequest: "/auth/verify",
  },
});

Route Handler#

typescript
// src/app/api/auth/[...nextauth]/route.ts
import { handlers } from "@/lib/auth";
 
export const { GET, POST } = handlers;

Middleware Koruması#

typescript
// src/middleware.ts
import { auth } from "@/lib/auth";
import { NextResponse } from "next/server";
 
export default auth((req) => {
  const isLoggedIn = !!req.auth;
  const isAuthPage = req.nextUrl.pathname.startsWith("/login")
    || req.nextUrl.pathname.startsWith("/register");
  const isProtectedRoute = req.nextUrl.pathname.startsWith("/dashboard")
    || req.nextUrl.pathname.startsWith("/settings")
    || req.nextUrl.pathname.startsWith("/admin");
  const isAdminRoute = req.nextUrl.pathname.startsWith("/admin");
 
  // Giriş yapmış kullanıcıları auth sayfalarından yönlendir
  if (isLoggedIn && isAuthPage) {
    return NextResponse.redirect(new URL("/dashboard", req.nextUrl));
  }
 
  // Kimliği doğrulanmamış kullanıcıları giriş sayfasına yönlendir
  if (!isLoggedIn && isProtectedRoute) {
    const callbackUrl = encodeURIComponent(req.nextUrl.pathname);
    return NextResponse.redirect(
      new URL(`/login?callbackUrl=${callbackUrl}`, req.nextUrl)
    );
  }
 
  // Admin rolünü kontrol et
  if (isAdminRoute && req.auth?.user?.role !== "admin") {
    return NextResponse.redirect(new URL("/dashboard", req.nextUrl));
  }
 
  return NextResponse.next();
});
 
export const config = {
  matcher: [
    "/dashboard/:path*",
    "/settings/:path*",
    "/admin/:path*",
    "/login",
    "/register",
  ],
};

Session'ı Server Component'lerde Kullanma#

typescript
// src/app/dashboard/page.tsx
import { auth } from "@/lib/auth";
import { redirect } from "next/navigation";
 
export default async function DashboardPage() {
  const session = await auth();
 
  if (!session?.user) {
    redirect("/login");
  }
 
  return (
    <div>
      <h1>Hoş geldin, {session.user.name}</h1>
      <p>Rol: {session.user.role}</p>
    </div>
  );
}

Session'ı Client Component'lerde Kullanma#

typescript
"use client";
 
import { useSession } from "next-auth/react";
 
export function UserMenu() {
  const { data: session, status } = useSession();
 
  if (status === "loading") {
    return <div>Yükleniyor...</div>;
  }
 
  if (status === "unauthenticated") {
    return <a href="/login">Giriş Yap</a>;
  }
 
  return (
    <div>
      <img
        src={session?.user?.image ?? "/default-avatar.png"}
        alt={session?.user?.name ?? "Kullanıcı"}
      />
      <span>{session?.user?.name}</span>
    </div>
  );
}

Passkey'ler (WebAuthn)#

Passkey'ler yıllardır kimlik doğrulama alanında en önemli gelişmedir. Oltalamaya dayanıklı, tekrar oynatmaya dayanıklı ve şifre ile ilgili güvenlik açıklarının tüm kategorisini ortadan kaldırır. 2026'da yeni bir proje başlıyorsanız, passkey'leri desteklemelisiniz.

Passkey'ler Nasıl Çalışır#

Passkey'ler, biyometri veya cihaz PIN'leri tarafından desteklenen açık anahtar kriptografisi kullanır:

  1. Kayıt: Tarayıcı bir anahtar çifti oluşturur. Özel anahtar cihazda kalır (güvenli bir alanda, biyometri ile korunan). Genel anahtar sunucunuza gönderilir.
  2. Kimlik Doğrulama: Sunucu bir meydan okuma (rastgele baytlar) gönderir. Cihaz, meydan okumayı özel anahtarla imzalar (biyometrik doğrulamadan sonra). Sunucu, saklanan genel anahtarla imzayı doğrular.

Paylaşılan hiçbir sır ağ üzerinden geçmez. Oltalanacak, sızacak veya doldurulacak hiçbir şey yoktur.

Passkey'ler Neden Oltalamaya Dayanıklı#

Bir passkey oluşturulduğunda, origin'e (örneğin, https://example.com) bağlanır. Tarayıcı, passkey'i yalnızca oluşturulduğu tam origin'de kullanacaktır. Bir saldırgan https://exarnple.com adresinde benzer bir site oluşturursa, passkey basitçe sunulmayacaktır. Bu, kullanıcı ihtiyatlılığı tarafından değil, tarayıcı tarafından uygulanır.

Bu, kullanıcıların sayfa doğru göründüğü için rutin olarak kimlik bilgilerini oltalama sitelerine girdiği şifrelerden temelden farklıdır.

SimpleWebAuthn ile İmplementasyon#

SimpleWebAuthn, önerdiğim kütüphanedir. WebAuthn protokolünü doğru bir şekilde ele alır ve iyi TypeScript tipleri vardır.

typescript
// Sunucu taraflı: Kayıt
import {
  generateRegistrationOptions,
  verifyRegistrationResponse,
} from "@simplewebauthn/server";
import type {
  GenerateRegistrationOptionsOpts,
  VerifiedRegistrationResponse,
} from "@simplewebauthn/server";
 
const rpName = "akousa.net";
const rpID = "akousa.net";
const origin = "https://akousa.net";
 
async function startRegistration(userId: string, userEmail: string) {
  // Hariç tutmak için kullanıcının mevcut passkey'lerini al
  const existingCredentials = await db.credential.findMany({
    where: { userId },
    select: { credentialId: true, transports: true },
  });
 
  const options: GenerateRegistrationOptionsOpts = {
    rpName,
    rpID,
    userID: new TextEncoder().encode(userId),
    userName: userEmail,
    attestationType: "none", // Çoğu uygulama için attestation'a ihtiyacımız yok
    excludeCredentials: existingCredentials.map((cred) => ({
      id: cred.credentialId,
      transports: cred.transports,
    })),
    authenticatorSelection: {
      residentKey: "preferred",
      userVerification: "preferred",
    },
  };
 
  const registrationOptions = await generateRegistrationOptions(options);
 
  // Meydan okumayı geçici olarak saklayın — doğrulama için ihtiyacımız var
  await redis.set(
    `webauthn:challenge:${userId}`,
    registrationOptions.challenge,
    "EX",
    300 // 5 dakika süre sonu
  );
 
  return registrationOptions;
}
 
async function finishRegistration(userId: string, response: unknown) {
  const expectedChallenge = await redis.get(`webauthn:challenge:${userId}`);
 
  if (!expectedChallenge) {
    throw new Error("Meydan okuma süresi doldu veya bulunamadı");
  }
 
  let verification: VerifiedRegistrationResponse;
  try {
    verification = await verifyRegistrationResponse({
      response: response as any,
      expectedChallenge,
      expectedOrigin: origin,
      expectedRPID: rpID,
    });
  } catch (error) {
    throw new Error(`Kayıt doğrulaması başarısız oldu: ${error}`);
  }
 
  if (!verification.verified || !verification.registrationInfo) {
    throw new Error("Kayıt doğrulaması başarısız oldu");
  }
 
  const { credential } = verification.registrationInfo;
 
  // Kimlik bilgisini veritabanında saklayın
  await db.credential.create({
    data: {
      userId,
      credentialId: credential.id,
      publicKey: Buffer.from(credential.publicKey),
      counter: credential.counter,
      transports: credential.transports ?? [],
    },
  });
 
  // Temizlik
  await redis.del(`webauthn:challenge:${userId}`);
 
  return { verified: true };
}
typescript
// Sunucu taraflı: Kimlik Doğrulama
import {
  generateAuthenticationOptions,
  verifyAuthenticationResponse,
} from "@simplewebauthn/server";
 
async function startAuthentication(userId?: string) {
  let allowCredentials;
 
  // Kullanıcıyı biliyorsak (örneğin, e-postasını girdi), passkey'lerini sınırla
  if (userId) {
    const credentials = await db.credential.findMany({
      where: { userId },
      select: { credentialId: true, transports: true },
    });
    allowCredentials = credentials.map((cred) => ({
      id: cred.credentialId,
      transports: cred.transports,
    }));
  }
 
  const options = await generateAuthenticationOptions({
    rpID,
    allowCredentials,
    userVerification: "preferred",
  });
 
  // Doğrulama için meydan okumayı saklayın
  const challengeKey = userId
    ? `webauthn:auth:${userId}`
    : `webauthn:auth:${options.challenge}`;
 
  await redis.set(challengeKey, options.challenge, "EX", 300);
 
  return options;
}
 
async function finishAuthentication(
  response: any,
  expectedChallenge: string,
  userId: string
) {
  const credential = await db.credential.findUnique({
    where: { credentialId: response.id },
  });
 
  if (!credential) {
    throw new Error("Kimlik bilgisi bulunamadı");
  }
 
  const verification = await verifyAuthenticationResponse({
    response,
    expectedChallenge,
    expectedOrigin: origin,
    expectedRPID: rpID,
    credential: {
      id: credential.credentialId,
      publicKey: credential.publicKey,
      counter: credential.counter,
      transports: credential.transports,
    },
  });
 
  if (!verification.verified) {
    throw new Error("Kimlik doğrulama doğrulaması başarısız oldu");
  }
 
  // ÖNEMLİ: Tekrar oynatma saldırılarını önlemek için sayacı güncelleyin
  await db.credential.update({
    where: { credentialId: response.id },
    data: {
      counter: verification.authenticationInfo.newCounter,
    },
  });
 
  return { verified: true, userId: credential.userId };
}
typescript
// İstemci taraflı: Kayıt
import { startRegistration as webAuthnRegister } from "@simplewebauthn/browser";
 
async function registerPasskey() {
  // Sunucunuzdan seçenekleri alın
  const optionsResponse = await fetch("/api/auth/webauthn/register", {
    method: "POST",
  });
  const options = await optionsResponse.json();
 
  try {
    // Bu, tarayıcının passkey arayüzünü tetikler (biyometrik istem)
    const credential = await webAuthnRegister(options);
 
    // Kimlik bilgisini doğrulama için sunucunuza gönderin
    const verifyResponse = await fetch("/api/auth/webauthn/register/verify", {
      method: "POST",
      headers: { "Content-Type": "application/json" },
      body: JSON.stringify(credential),
    });
 
    const result = await verifyResponse.json();
    if (result.verified) {
      console.log("Passkey başarıyla kaydedildi!");
    }
  } catch (error) {
    if ((error as Error).name === "NotAllowedError") {
      console.log("Kullanıcı passkey kaydını iptal etti");
    }
  }
}

Attestation vs Assertion#

Karşılaşacağınız iki terim:

  • Attestation (kayıt): Yeni bir kimlik bilgisi oluşturma süreci. Doğrulayıcı, kimliğini ve yeteneklerini "onaylar." Çoğu uygulama için attestation'ı doğrulamanız gerekmez — attestationType: "none" ayarlayın.
  • Assertion (kimlik doğrulama): Mevcut bir kimlik bilgisini kullanarak bir meydan okumayı imzalama süreci. Doğrulayıcı, kullanıcının iddia ettiği kişi olduğunu "ileri sürer."

MFA İmplementasyonu#

Passkey'lere rağmen, TOTP aracılığıyla MFA'nın gerekli olduğu senaryolarla karşılaşacaksınız — şifrelerin yanında ikinci faktör olarak passkey'ler veya cihazları passkey'leri desteklemeyen kullanıcıları desteklemek.

TOTP (Zamana Dayalı Tek Kullanımlık Şifreler)#

TOTP, Google Authenticator, Authy ve 1Password'ün arkasındaki protokoldür. Şu şekilde çalışır:

  1. Sunucu rastgele bir gizli anahtar oluşturur (base32 kodlu)
  2. Kullanıcı gizli anahtarı içeren bir QR kodu tarar
  3. Hem sunucu hem de doğrulayıcı uygulaması, gizli anahtar ve geçerli zamandan aynı 6 haneli kodu hesaplar
  4. Kodlar her 30 saniyede değişir
typescript
import { createHmac, randomBytes } from "crypto";
 
// Bir kullanıcı için TOTP gizli anahtarı oluşturun
function generateTOTPSecret(): string {
  const buffer = randomBytes(20);
  return base32Encode(buffer);
}
 
function base32Encode(buffer: Buffer): string {
  const alphabet = "ABCDEFGHIJKLMNOPQRSTUVWXYZ234567";
  let result = "";
  let bits = 0;
  let value = 0;
 
  for (const byte of buffer) {
    value = (value << 8) | byte;
    bits += 8;
    while (bits >= 5) {
      result += alphabet[(value >>> (bits - 5)) & 0x1f];
      bits -= 5;
    }
  }
 
  if (bits > 0) {
    result += alphabet[(value << (5 - bits)) & 0x1f];
  }
 
  return result;
}
 
// QR kodu için TOTP URI'si oluşturun
function generateTOTPUri(
  secret: string,
  userEmail: string,
  issuer: string = "akousa.net"
): string {
  const encodedIssuer = encodeURIComponent(issuer);
  const encodedEmail = encodeURIComponent(userEmail);
  return `otpauth://totp/${encodedIssuer}:${encodedEmail}?secret=${secret}&issuer=${encodedIssuer}&algorithm=SHA1&digits=6&period=30`;
}
typescript
// Bir TOTP kodunu doğrulayın
function verifyTOTP(secret: string, code: string, window: number = 1): boolean {
  const secretBuffer = base32Decode(secret);
  const now = Math.floor(Date.now() / 1000);
 
  // Geçerli zaman adımını ve komşularını kontrol edin (saat kayması toleransı)
  for (let i = -window; i <= window; i++) {
    const timeStep = Math.floor(now / 30) + i;
    const expectedCode = generateTOTPCode(secretBuffer, timeStep);
 
    // Zamanlama saldırılarını önlemek için sabit zamanlı karşılaştırma
    if (timingSafeEqual(code, expectedCode)) {
      return true;
    }
  }
 
  return false;
}
 
function generateTOTPCode(secret: Buffer, timeStep: number): string {
  // Zaman adımını 8 baytlık big-endian buffer'a dönüştür
  const timeBuffer = Buffer.alloc(8);
  timeBuffer.writeBigInt64BE(BigInt(timeStep));
 
  // HMAC-SHA1
  const hmac = createHmac("sha1", secret).update(timeBuffer).digest();
 
  // Dinamik kesme
  const offset = hmac[hmac.length - 1] & 0x0f;
  const code =
    ((hmac[offset] & 0x7f) << 24) |
    ((hmac[offset + 1] & 0xff) << 16) |
    ((hmac[offset + 2] & 0xff) << 8) |
    (hmac[offset + 3] & 0xff);
 
  return (code % 1_000_000).toString().padStart(6, "0");
}
 
function timingSafeEqual(a: string, b: string): boolean {
  if (a.length !== b.length) return false;
  const bufA = Buffer.from(a);
  const bufB = Buffer.from(b);
  return createHmac("sha256", "key").update(bufA).digest()
    .equals(createHmac("sha256", "key").update(bufB).digest());
}

Yedek Kodlar#

Kullanıcılar telefonlarını kaybeder. MFA kurulumu sırasında her zaman yedek kodlar oluşturun:

typescript
import { randomBytes, createHash } from "crypto";
 
function generateBackupCodes(count: number = 10): string[] {
  return Array.from({ length: count }, () =>
    randomBytes(4).toString("hex").toUpperCase() // 8 karakterlik hex kodları
  );
}
 
async function storeBackupCodes(userId: string, codes: string[]) {
  // Saklamadan önce kodları hash'leyin — onlara şifreler gibi davranın
  const hashedCodes = codes.map((code) =>
    createHash("sha256").update(code).digest("hex")
  );
 
  await db.backupCode.createMany({
    data: hashedCodes.map((hash) => ({
      userId,
      codeHash: hash,
      used: false,
    })),
  });
 
  // Düz kodları kullanıcının kaydetmesi için BİR KERE döndürün
  // Bundan sonra, yalnızca hash'lere sahibiz
  return codes;
}
 
async function verifyBackupCode(userId: string, code: string): Promise<boolean> {
  const codeHash = createHash("sha256")
    .update(code.toUpperCase().replace(/\s/g, ""))
    .digest("hex");
 
  const backupCode = await db.backupCode.findFirst({
    where: {
      userId,
      codeHash,
      used: false,
    },
  });
 
  if (!backupCode) return false;
 
  // Kullanılmış olarak işaretle — her yedek kod tam olarak bir kez çalışır
  await db.backupCode.update({
    where: { id: backupCode.id },
    data: { used: true, usedAt: new Date() },
  });
 
  return true;
}

Kurtarma Akışı#

MFA kurtarma, çoğu öğreticinin atladığı ve çoğu gerçek uygulamanın berbat ettiği kısımdır. İşte implemente ettiklerim:

  1. Birincil: Doğrulayıcı uygulamasından TOTP kodu
  2. İkincil: 10 yedek koddan biri
  3. Son çare: 24 saatlik bekleme süresi ve kullanıcının diğer doğrulanmış kanallarına bildirim içeren e-posta tabanlı kurtarma

Bekleme süresi kritiktir. Bir saldırgan kullanıcının e-postasını ele geçirdiyse, MFA'yı anında devre dışı bırakmasına izin vermek istemezsiniz. 24 saatlik gecikme, meşru kullanıcıya e-postayı fark edip müdahale etme zamanı verir.

typescript
async function initiateAccountRecovery(email: string) {
  const user = await db.user.findUnique({ where: { email } });
  if (!user) {
    // Hesabın var olup olmadığını ifşa etmeyin
    return { message: "Bu e-posta mevcutsa, kurtarma talimatları gönderdik." };
  }
 
  const recoveryToken = randomBytes(32).toString("hex");
  const tokenHash = createHash("sha256").update(recoveryToken).digest("hex");
 
  await db.recoveryRequest.create({
    data: {
      userId: user.id,
      tokenHash,
      expiresAt: new Date(Date.now() + 24 * 60 * 60 * 1000), // 24 saat
      status: "pending",
    },
  });
 
  // Kurtarma bağlantısı içeren e-posta gönderin
  await sendEmail(email, {
    subject: "Hesap Kurtarma Talebi",
    body: `
      Hesabınızda MFA'yı devre dışı bırakma talebi yapıldı.
      Bu sizseniz, 24 saat sonra aşağıdaki bağlantıya tıklayın: ...
      Bu siz DEĞİLSENİZ, lütfen şifrenizi hemen değiştirin.
    `,
  });
 
  return { message: "Bu e-posta mevcutsa, kurtarma talimatları gönderdik." };
}

Yetkilendirme Kalıpları#

Kimlik doğrulama size birinin kim olduğunu söyler. Yetkilendirme ise ne yapmalarına izin verildiğini söyler. Bunu yanlış yapmak haberlere çıkmanıza yol açar.

RBAC vs ABAC#

RBAC (Rol Tabanlı Erişim Kontrolü): Kullanıcıların rolleri vardır, rollerin izinleri vardır. Basit, akıl yürütmesi kolay, çoğu uygulama için çalışır.

typescript
// RBAC — doğrudan rol kontrolleri
type Role = "user" | "editor" | "admin" | "super_admin";
 
const ROLE_PERMISSIONS: Record<Role, string[]> = {
  user: ["read:own_profile", "update:own_profile", "read:posts"],
  editor: ["read:own_profile", "update:own_profile", "read:posts", "create:posts", "update:posts"],
  admin: [
    "read:own_profile", "update:own_profile",
    "read:posts", "create:posts", "update:posts", "delete:posts",
    "read:users", "update:users",
  ],
  super_admin: ["*"], // Joker karakterlerle dikkatli olun
};
 
function hasPermission(role: Role, permission: string): boolean {
  const permissions = ROLE_PERMISSIONS[role];
  return permissions.includes("*") || permissions.includes(permission);
}
 
// Bir API route'unda kullanım
export async function DELETE(
  request: Request,
  { params }: { params: Promise<{ id: string }> }
) {
  const session = await auth();
  if (!session?.user) {
    return Response.json({ error: "Yetkisiz" }, { status: 401 });
  }
 
  if (!hasPermission(session.user.role as Role, "delete:posts")) {
    return Response.json({ error: "Yasaklı" }, { status: 403 });
  }
 
  const { id } = await params;
  await db.post.delete({ where: { id } });
  return Response.json({ success: true });
}

ABAC (Özellik Tabanlı Erişim Kontrolü): İzinler kullanıcının, kaynağın ve bağlamın özelliklerine bağlıdır. Daha esnek ama daha karmaşık.

typescript
// ABAC — RBAC yeterli olmadığında
interface PolicyContext {
  user: {
    id: string;
    role: string;
    department: string;
    clearanceLevel: number;
  };
  resource: {
    type: string;
    ownerId: string;
    classification: string;
    department: string;
  };
  action: string;
  environment: {
    ipAddress: string;
    time: Date;
    mfaVerified: boolean;
  };
}
 
function evaluatePolicy(context: PolicyContext): boolean {
  const { user, resource, action, environment } = context;
 
  // Kullanıcılar kendi kaynaklarını her zaman okuyabilir
  if (action === "read" && resource.ownerId === user.id) {
    return true;
  }
 
  // Adminler kendi departmanlarındaki herhangi bir kaynağı okuyabilir
  if (
    action === "read" &&
    user.role === "admin" &&
    user.department === resource.department
  ) {
    return true;
  }
 
  // Gizli kaynaklar MFA ve minimum yetki seviyesi gerektirir
  if (resource.classification === "confidential") {
    if (!environment.mfaVerified) return false;
    if (user.clearanceLevel < 3) return false;
  }
 
  // Yıkıcı eylemler mesai saatleri dışında engellenir
  if (action === "delete") {
    const hour = environment.time.getHours();
    if (hour < 9 || hour > 17) return false;
  }
 
  return false; // Varsayılan reddet
}

"Sınırda Kontrol Et" Kuralı#

Bu, en önemli tek yetkilendirme ilkesidir: her güven sınırında izinleri kontrol edin, yalnızca arayüz seviyesinde değil.

typescript
// KÖTÜ — yalnızca bileşende kontrol
function DeleteButton({ post }: { post: Post }) {
  const { data: session } = useSession();
 
  // Bu düğmeyi gizler, ama silmeyi engellemez
  if (session?.user?.role !== "admin") return null;
 
  return <button onClick={() => deletePost(post.id)}>Sil</button>;
}
 
// AYRICA KÖTÜ — bir sunucu eyleminde kontrol ama API route'unda değil
async function deletePostAction(postId: string) {
  const session = await auth();
  if (session?.user?.role !== "admin") throw new Error("Yasaklı");
  await db.post.delete({ where: { id: postId } });
}
// Bir saldırgan yine de POST /api/posts/123'ü doğrudan çağırabilir
 
// İYİ — her sınırda kontrol et
// 1. Arayüzde düğmeyi gizle (UX, güvenlik değil)
// 2. Sunucu eyleminde kontrol et (derinlemesine savunma)
// 3. API route'unda kontrol et (gerçek güvenlik sınırı)
// 4. İsteğe bağlı olarak, middleware'de kontrol et (route seviyesinde koruma)

Arayüz kontrolü kullanıcı deneyimi içindir. Sunucu kontrolü güvenlik içindir. Asla yalnızca birine güvenmeyin.

Next.js Middleware'de İzin Kontrolleri#

Middleware, eşleşen her istekten önce çalışır. Kaba taneli erişim kontrolü için iyi bir yerdir:

typescript
// "Bu kullanıcının bu bölüme erişmesine izin veriliyor mu?"
// İnce taneli kontroller ("Bu kullanıcı BU gönderiyi düzenleyebilir mi?") route handler'da olmalıdır
// çünkü middleware istek gövdesine veya route parametrelerine kolayca erişemez.
 
export default auth((req) => {
  const path = req.nextUrl.pathname;
  const role = req.auth?.user?.role;
 
  // Route seviyesinde erişim kontrolü
  const routeAccess: Record<string, Role[]> = {
    "/admin": ["admin", "super_admin"],
    "/editor": ["editor", "admin", "super_admin"],
    "/dashboard": ["user", "editor", "admin", "super_admin"],
  };
 
  for (const [route, allowedRoles] of Object.entries(routeAccess)) {
    if (path.startsWith(route)) {
      if (!role || !allowedRoles.includes(role as Role)) {
        return NextResponse.redirect(new URL("/unauthorized", req.nextUrl));
      }
    }
  }
 
  return NextResponse.next();
});

Yaygın Güvenlik Açıkları#

Bunlar gerçek kod tabanlarında en sık gördüğüm saldırılardır. Bunları anlamak zorunludur.

Session Sabitleme#

Saldırı: Bir saldırgan sitenizde geçerli bir session oluşturur, ardından kurbanı bu session ID'yi kullanmaya kandırır (örneğin, bir URL parametresi aracılığıyla veya bir alt alan adı üzerinden çerez ayarlayarak). Kurban giriş yaptığında, saldırganın session'ı artık kimliği doğrulanmış bir kullanıcıya sahiptir.

Çözüm: Başarılı kimlik doğrulamadan sonra session ID'yi her zaman yeniden oluşturun. Kimlik doğrulama öncesi session ID'nin kimlik doğrulama sonrası session'a taşınmasına asla izin vermeyin.

typescript
async function login(credentials: { email: string; password: string }, request: Request) {
  const user = await verifyCredentials(credentials);
  if (!user) throw new Error("Geçersiz kimlik bilgileri");
 
  // KRİTİK: Eski session'ı sil ve yeni bir tane oluştur
  const oldSessionId = getSessionIdFromCookie(request);
  if (oldSessionId) {
    await redis.del(`session:${oldSessionId}`);
  }
 
  // Yeni bir ID ile tamamen yeni bir session oluştur
  const newSessionId = await createSession(user.id, request);
  return newSessionId;
}

CSRF (Cross-Site Request Forgery)#

Saldırı: Bir kullanıcı sitenize giriş yapmıştır. Sitenize istek yapan kötü amaçlı bir sayfayı ziyaret eder. Çerezler otomatik olarak gönderildiği için, istek kimliği doğrulanmıştır.

Modern çözüm: SameSite çerezler. SameSite: Lax ayarı (artık çoğu tarayıcıda varsayılan), çapraz origin POST isteklerinde çerezlerin gönderilmesini engeller ve bu çoğu CSRF senaryosunu kapsar.

typescript
// SameSite=Lax çoğu CSRF senaryosunu kapsar:
// - Çapraz origin POST, PUT, DELETE'de çerezleri engeller
// - Çapraz origin GET'te çerezlere izin verir (üst düzey navigasyon)
//   Bu sorun değil çünkü GET isteklerinin yan etkileri olmamalıdır
 
cookieStore.set("session_id", sessionId, {
  httpOnly: true,
  secure: true,
  sameSite: "lax",  // Bu sizin CSRF korumanızdır
  maxAge: 86400,
  path: "/",
});

JSON kabul eden API'ler için ek koruma ücretsiz gelir: Content-Type: application/json başlığı HTML formları tarafından ayarlanamaz ve CORS diğer origin'lerdeki JavaScript'in özel başlıklarla istek yapmasını engeller.

Daha güçlü garantilere ihtiyacınız varsa (örneğin, form gönderilerini kabul ediyorsanız), çift gönderimli çerez kalıbı veya senkronize edici token kullanın. Auth.js bunu sizin için ele alır.

OAuth'ta Açık Yönlendirmeler#

Saldırı: Bir saldırgan, kimlik doğrulama sonrasında kendi sitesine yönlendiren bir OAuth callback URL'si hazırlar: https://sizinapp.com/callback?redirect_to=https://kotu.com/token-cali

Callback handler'ınız redirect_to parametresine körü körüne yönlendirirse, kullanıcı saldırganın sitesine ulaşır ve potansiyel olarak URL'de token'larla birlikte.

typescript
// GÜVENLİK AÇIĞI
async function handleCallback(request: Request) {
  const url = new URL(request.url);
  const redirectTo = url.searchParams.get("redirect_to") ?? "/";
  // ... kullanıcının kimliğini doğrula ...
  return Response.redirect(redirectTo); // https://kotu.com olabilir!
}
 
// GÜVENLİ
async function handleCallback(request: Request) {
  const url = new URL(request.url);
  const redirectTo = url.searchParams.get("redirect_to") ?? "/";
 
  // Yönlendirme URL'sini doğrula
  const safeRedirect = sanitizeRedirectUrl(redirectTo, request.url);
  // ... kullanıcının kimliğini doğrula ...
  return Response.redirect(safeRedirect);
}
 
function sanitizeRedirectUrl(redirect: string, baseUrl: string): string {
  try {
    const url = new URL(redirect, baseUrl);
    const base = new URL(baseUrl);
 
    // Yalnızca aynı origin'e yönlendirmelere izin ver
    if (url.origin !== base.origin) {
      return "/";
    }
 
    // Yalnızca yol yönlendirmelerine izin ver (javascript: veya data: URI'leri yok)
    if (!url.pathname.startsWith("/")) {
      return "/";
    }
 
    return url.pathname + url.search;
  } catch {
    return "/";
  }
}

Referrer Aracılığıyla Token Sızıntısı#

Token'ları URL'lere koyarsanız (koymayın), kullanıcılar bağlantılara tıkladığında Referer başlığı aracılığıyla sızacaktır. Bu, GitHub dahil gerçek ihlallere neden olmuştur.

Kurallar:

  • Kimlik doğrulama için URL sorgu parametrelerine asla token koymayın
  • Referrer-Policy: strict-origin-when-cross-origin (veya daha katı) ayarlayın
  • URL'lere token koymanız gerekiyorsa (örneğin, e-posta doğrulama bağlantıları), tek kullanımlık ve kısa ömürlü yapın
typescript
// Next.js middleware veya layout'unuzda
const headers = new Headers();
headers.set("Referrer-Policy", "strict-origin-when-cross-origin");

JWT Anahtar Enjeksiyonu#

Daha az bilinen bir saldırı: bazı JWT kütüphaneleri, doğrulayıcıya genel anahtarın nerede bulunacağını söyleyen bir jwk veya jku başlığını destekler. Bir saldırgan:

  1. Kendi anahtar çiftini oluşturabilir
  2. Kendi payload'ı ile bir JWT oluşturup özel anahtarıyla imzalayabilir
  3. jwk başlığını kendi genel anahtarına işaret edecek şekilde ayarlayabilir

Kütüphaneniz jwk başlığından anahtarı körü körüne getirip kullanırsa, imza doğrulanır. Çözüm: token'ın kendi doğrulama anahtarını belirlemesine asla izin vermeyin. Her zaman kendi yapılandırmanızdaki anahtarları kullanın.

2026'daki Auth Yığınım#

Yıllarca kimlik doğrulama sistemleri inşa ettikten sonra, bugün gerçekten kullandıklarım bunlar.

Çoğu Proje İçin: Auth.js + PostgreSQL + Passkey'ler#

Bu, yeni projeler için varsayılan yığınımdır:

  • Auth.js (v5) ağır iş için: OAuth sağlayıcıları, session yönetimi, CSRF, veritabanı adaptörü
  • PostgreSQL Prisma adaptörü ile session ve hesap depolama için
  • Passkey'ler SimpleWebAuthn aracılığıyla yeni kullanıcılar için birincil giriş yöntemi olarak
  • E-posta/şifre passkey'leri kullanamayan kullanıcılar için bir yedek olarak
  • TOTP MFA şifre tabanlı girişler için ikinci faktör olarak

Session stratejisi veritabanı desteklidir (JWT değil), bu bana anında iptal ve basit session yönetimi sağlar.

typescript
// Yeni bir proje için tipik auth.ts dosyam
import NextAuth from "next-auth";
import Google from "next-auth/providers/google";
import GitHub from "next-auth/providers/github";
import Passkey from "next-auth/providers/passkey";
import { PrismaAdapter } from "@auth/prisma-adapter";
import { prisma } from "@/lib/prisma";
 
export const { handlers, auth, signIn, signOut } = NextAuth({
  adapter: PrismaAdapter(prisma),
  session: { strategy: "database" },
  providers: [
    Google,
    GitHub,
    Passkey({
      // Auth.js v5 yerleşik passkey desteğine sahiptir
      // Bu, perde arkasında SimpleWebAuthn kullanır
    }),
  ],
  experimental: {
    enableWebAuthn: true,
  },
});

Ne Zaman Clerk veya Auth0 Kullanmalı#

Yönetilen bir auth sağlayıcısına şu durumlarda başvuruyorum:

  • Proje kurumsal SSO gerektiriyorsa (SAML, SCIM). SAML'ı doğru bir şekilde implemente etmek çok aylık bir projedir. Clerk bunu kutusundan çıkarır.
  • Takımın güvenlik uzmanlığı yoksa. Takımda kimse PKCE'yi açıklayamıyorsa, auth'u sıfırdan inşa etmemeliler.
  • Pazara çıkış süresi maliyetten önemliyse. Auth.js ücretsizdir ama düzgün kurulması günler alır. Clerk bir öğleden sonra sürer.
  • Uyumluluk garantilerine ihtiyacınız varsa (SOC 2, HIPAA). Yönetilen sağlayıcılar uyumluluk sertifikasyonunu üstlenir.

Yönetilen sağlayıcıların ödünleşimleri:

  • Maliyet: Clerk aylık aktif kullanıcı başına ücret alır. Ölçeklendiğinde bu birikir.
  • Satıcı kilidi: Clerk veya Auth0'dan göç etmek acı vericidir. Kullanıcı tablonuz onların sunucularındadır.
  • Özelleştirme sınırları: Auth akışınız olağandışıysa, sağlayıcının görüşleriyle savaşırsınız.
  • Gecikme: Her auth kontrolü üçüncü taraf bir API'ye gider. Veritabanı session'ları ile bu yerel bir sorgudur.

Nelerden Kaçınıyorum#

  • Kendi kriptomumu oluşturmak. JWT'ler için jose, passkey'ler için @simplewebauthn/server, şifreler için bcrypt veya argon2 kullanıyorum. Asla el yapımı değil.
  • Şifreleri SHA256'da saklamak. bcrypt (maliyet faktörü 12+) veya argon2id kullanın. SHA256 çok hızlıdır — saldırgan GPU ile saniyede milyarlarca hash deneyebilir.
  • Uzun ömürlü access token'lar. Maksimum 15 dakika. Daha uzun session'lar için refresh token rotasyonu kullanın.
  • Çapraz servis doğrulaması için simetrik gizli anahtarlar. Birden fazla servis token doğrulaması gerekiyorsa, genel/özel anahtar çifti ile RS256 kullanın.
  • Yetersiz entropi ile özel session ID'leri. En az crypto.randomBytes(32) kullanın. UUID v4 kabul edilebilir ama ham rastgele baytlardan daha az entropiye sahiptir.

Şifre Hashleme: Doğru Yol#

Bahsettiğimize göre — 2026'da şifreleri doğru şekilde nasıl hashlersiniz:

typescript
import { hash, verify } from "@node-rs/argon2";
 
// Argon2id önerilen algoritmadır
// Bunlar bir web uygulaması için makul varsayılanlar
async function hashPassword(password: string): Promise<string> {
  return hash(password, {
    memoryCost: 65536,  // 64 MB
    timeCost: 3,        // 3 iterasyon
    parallelism: 4,     // 4 iş parçacığı
  });
}
 
async function verifyPassword(
  password: string,
  hashedPassword: string
): Promise<boolean> {
  try {
    return await verify(hashedPassword, password);
  } catch {
    return false;
  }
}

Neden argon2id bcrypt'ten üstün? Argon2id bellek zorlayıcıdır, yani saldırmak yalnızca CPU gücü değil aynı zamanda büyük miktarlarda RAM da gerektirir. Bu, GPU ve ASIC saldırılarını önemli ölçüde daha pahalı hale getirir. bcrypt hala iyidir — kırılmamıştır — ama argon2id yeni projeler için daha iyi bir seçimdir.

Güvenlik Kontrol Listesi#

Herhangi bir kimlik doğrulama sistemini yayınlamadan önce doğrulayın:

  • Şifreler argon2id veya bcrypt (maliyet 12+) ile hashlenmiş
  • Session'lar girişten sonra yeniden oluşturuluyor (session sabitlemeyi önler)
  • Çerezler HttpOnly, Secure, SameSite=Lax veya Strict
  • JWT'ler algoritmaları açıkça belirtiyor (asla alg başlığına güvenme)
  • Access token'lar 15 dakika veya daha kısa sürede sona eriyor
  • Refresh token rotasyonu yeniden kullanım tespiti ile implemente edilmiş
  • OAuth state parametresi doğrulanıyor (CSRF koruması)
  • Yönlendirme URL'leri bir izin listesine karşı doğrulanıyor
  • Giriş, kayıt ve şifre sıfırlama endpoint'lerine hız sınırlaması uygulanıyor
  • Başarısız giriş denemeleri IP ile günlükleniyor ama şifrelerle değil
  • N başarısız denemeden sonra hesap kilitleme (kademeli gecikmelerle, kalıcı kilit değil)
  • Şifre sıfırlama token'ları tek kullanımlık ve 1 saat içinde sona eriyor
  • MFA yedek kodları şifreler gibi hashlenmiş
  • CORS yalnızca bilinen origin'lere izin verecek şekilde yapılandırılmış
  • Referrer-Policy başlığı ayarlanmış
  • JWT payload'larında hassas veri yok (herkes tarafından okunabilir)
  • WebAuthn sayacı doğrulanıyor ve güncelleniyor (kimlik bilgisi klonlamayı önler)

Bu liste kapsamlı değildir, ama production sistemlerinde en sık gördüğüm güvenlik açıklarını kapsar.

Sonuç#

Kimlik doğrulama, manzaranın sürekli geliştiği ama temellerin aynı kaldığı alanlardan biridir: kimliği doğrula, gereken minimum kimlik bilgilerini ver, her sınırda izinleri kontrol et ve ihlali varsay.

2026'daki en büyük değişim passkey'lerin ana akım olmasıdır. Tarayıcı desteği evrenseldir, platform desteği (iCloud Keychain, Google Şifre Yöneticisi) kullanıcı deneyimini sorunsuz hale getirir ve güvenlik özellikleri daha önce sahip olduğumuz her şeyden gerçekten üstündür. Yeni bir uygulama yapıyorsanız, passkey'leri birincil giriş yönteminiz yapın ve şifreleri yedek olarak ele alın.

İkinci en büyük değişim, kendi auth'unuzu yazmanın gerekçelendirilmesinin zorlaşmasıdır. Auth.js v5, Clerk ve benzeri çözümler zor kısımları doğru bir şekilde ele alır. Özel gidebilmenizin tek nedeni, gereksinimlerinizin gerçekten mevcut hiçbir çözüme uymamasıdır — ve bu, çoğu geliştiricinin düşündüğünden daha nadirdir.

Ne seçerseniz seçin, auth'unuzu bir saldırgan gibi test edin. Token'ları tekrar oynatmayı, imzaları sahtelemeyi, olmamanız gereken route'lara erişmeyi ve yönlendirme URL'lerini manipüle etmeyi deneyin. Lansmandan önce bulduğunuz hatalar, haberlere çıkmayan hatalardır.

İlgili Yazılar