Cloudflare Workers ile Güvenli Giriş Sistemi Oluşturma
Geçen ay bir müşterimizin login sayfası brute force saldırısına uğradı. Klasik yöntem: origin server önünde Nginx rate limiting, fail2ban, birkaç iptables kuralı. Ama trafik zaten origin’e geliyordu, sunucu çırpınıyordu. İşte tam o noktada “bunu neden edge’de çözmüyoruz?” sorusunu sorduk kendimize. Cloudflare Workers bu iş için biçilmiş kaftan.
Bu yazıda sıfırdan, production’a hazır bir güvenli giriş sistemi kuracağız. Sadece rate limiting değil; JWT doğrulama, coğrafi kısıtlama, bot tespiti ve şüpheli IP bloklama. Hepsini kullanıcı isteği origin sunucunuza ulaşmadan önce edge’de halledeceğiz.
Neden Edge’de Authentication?
Origin önüne bir WAF koymak artık yeterli değil. Saldırılar daha akıllı, dağıtık ve hedefli hale geldi. Cloudflare’in global edge ağı 300’den fazla şehirde PoP’a sahip. Türkiye’deki bir kullanıcı login isteği attığında, bu istek Istanbul edge node’unda işlenebilir. Origin sunucunuz Ankara’daki bir data center’da olsa bile, kötü niyetli trafik hiç oraya ulaşmaz.
Performans tarafında da ciddi kazanımlar var. Edge’deki bir Worker, cold start dahil genellikle 5ms altında yanıt veriyor. Bunu kendi sunucunuzdaki bir middleware ile kıyasladığınızda fark ortaya çıkıyor.
Ortamı Hazırlama
Wrangler CLI olmadan Workers geliştirmek işkenceye dönüşür. Hemen kuralım:
npm install -g wrangler
wrangler login
wrangler init secure-login-worker --type=javascript
cd secure-login-worker
wrangler.toml dosyamızı düzenleyelim. KV namespace’i rate limiting ve session verisi için kullanacağız:
cat > wrangler.toml << 'EOF'
name = "secure-login-worker"
main = "src/index.js"
compatibility_date = "2024-01-01"
[[kv_namespaces]]
binding = "RATE_LIMIT_KV"
id = "buraya_kv_namespace_id_gelecek"
preview_id = "buraya_preview_id_gelecek"
[vars]
JWT_ALGORITHM = "HS256"
MAX_ATTEMPTS = "5"
LOCKOUT_DURATION = "900"
GEO_RESTRICT = "false"
[[secrets]]
name = "JWT_SECRET"
EOF
KV namespace oluşturalım ve ID’yi alalım:
wrangler kv:namespace create "RATE_LIMIT_KV"
wrangler kv:namespace create "RATE_LIMIT_KV" --preview
# Çıktıdaki ID'leri wrangler.toml'a yapıştır
wrangler secret put JWT_SECRET
# Güçlü bir secret girin, en az 32 karakter
Rate Limiting Katmanı
İlk savunma hattı rate limiting. Cloudflare’in kendi rate limiting özelliği var ama Workers içinde bunu KV ile kendiniz yönettiğinizde çok daha granüler kontrol elde ediyorsunuz. Örneğin “aynı IP’den 5 dakikada 5 başarısız deneme” gibi mantıkları kolayca ifade edebiliyorsunuz:
// src/rateLimit.js
export async function checkRateLimit(ip, kv, maxAttempts, lockoutDuration) {
const key = `ratelimit:${ip}`;
const now = Math.floor(Date.now() / 1000);
const existing = await kv.get(key, { type: "json" });
if (!existing) {
await kv.put(key, JSON.stringify({
attempts: 1,
firstAttempt: now,
lastAttempt: now,
blocked: false
}), { expirationTtl: lockoutDuration });
return { allowed: true, remaining: maxAttempts - 1 };
}
// Lockout kontrolü
if (existing.blocked) {
const timeLeft = lockoutDuration - (now - existing.lastAttempt);
return {
allowed: false,
reason: "ip_blocked",
retryAfter: Math.max(0, timeLeft)
};
}
// Pencere dışında mı?
if (now - existing.firstAttempt > 300) {
// 5 dakikalık pencere geçmiş, sıfırla
await kv.put(key, JSON.stringify({
attempts: 1,
firstAttempt: now,
lastAttempt: now,
blocked: false
}), { expirationTtl: lockoutDuration });
return { allowed: true, remaining: maxAttempts - 1 };
}
const newAttempts = existing.attempts + 1;
if (newAttempts >= maxAttempts) {
await kv.put(key, JSON.stringify({
...existing,
attempts: newAttempts,
lastAttempt: now,
blocked: true
}), { expirationTtl: lockoutDuration });
return {
allowed: false,
reason: "rate_limit_exceeded",
retryAfter: lockoutDuration
};
}
await kv.put(key, JSON.stringify({
...existing,
attempts: newAttempts,
lastAttempt: now
}), { expirationTtl: lockoutDuration });
return { allowed: true, remaining: maxAttempts - newAttempts };
}
export async function recordSuccessfulLogin(ip, kv) {
const key = `ratelimit:${ip}`;
await kv.delete(key);
}
JWT Üretme ve Doğrulama
Workers ortamında Node.js crypto modülü yok. Web Crypto API kullanıyoruz. Bu kısım birçok insanın tökezlediği nokta; jsonwebtoken paketi Workers’ta çalışmaz:
// src/jwt.js
const encoder = new TextEncoder();
async function importKey(secret) {
return crypto.subtle.importKey(
"raw",
encoder.encode(secret),
{ name: "HMAC", hash: "SHA-256" },
false,
["sign", "verify"]
);
}
function base64UrlEncode(data) {
return btoa(String.fromCharCode(...new Uint8Array(data)))
.replace(/+/g, "-")
.replace(///g, "_")
.replace(/=/g, "");
}
function base64UrlDecode(str) {
str = str.replace(/-/g, "+").replace(/_/g, "/");
while (str.length % 4) str += "=";
return atob(str);
}
export async function createJWT(payload, secret, expiresIn = 3600) {
const header = { alg: "HS256", typ: "JWT" };
const now = Math.floor(Date.now() / 1000);
const fullPayload = {
...payload,
iat: now,
exp: now + expiresIn,
jti: crypto.randomUUID()
};
const headerEncoded = base64UrlEncode(encoder.encode(JSON.stringify(header)));
const payloadEncoded = base64UrlEncode(encoder.encode(JSON.stringify(fullPayload)));
const signingInput = `${headerEncoded}.${payloadEncoded}`;
const key = await importKey(secret);
const signature = await crypto.subtle.sign(
"HMAC",
key,
encoder.encode(signingInput)
);
return `${signingInput}.${base64UrlEncode(signature)}`;
}
export async function verifyJWT(token, secret) {
try {
const parts = token.split(".");
if (parts.length !== 3) throw new Error("Geçersiz token formatı");
const [headerEncoded, payloadEncoded, signatureEncoded] = parts;
const signingInput = `${headerEncoded}.${payloadEncoded}`;
const key = await importKey(secret);
const signatureBytes = Uint8Array.from(
base64UrlDecode(signatureEncoded),
c => c.charCodeAt(0)
);
const valid = await crypto.subtle.verify(
"HMAC",
key,
signatureBytes,
encoder.encode(signingInput)
);
if (!valid) throw new Error("İmza doğrulanamadı");
const payload = JSON.parse(base64UrlDecode(payloadEncoded));
const now = Math.floor(Date.now() / 1000);
if (payload.exp && payload.exp < now) {
throw new Error("Token süresi dolmuş");
}
return { valid: true, payload };
} catch (error) {
return { valid: false, error: error.message };
}
}
Bot Tespiti
Cloudflare, her request’e cf objesi ekler. Bu obje içinde Türkiye’ye özgü senaryolarda çok işe yarayan bot skoru, ASN bilgisi ve tehdit skoru bulunur. Bir e-ticaret müşterisinde ASN bazlı filtreleme yaparak Türkiye kaynaklı trafiği hosting/VPN ASN’lerinden ayırdık; false positive’i minimize ettik:
// src/botDetection.js
// Bilinen kötü ASN'ler - düzenli güncellenmeli
const SUSPICIOUS_ASNS = [
"AS209588", // Örnek: belirli bir VPN sağlayıcısı
"AS396356", // Örnek: hosting bloğu
];
export function analyzeBotSignals(request) {
const cf = request.cf || {};
const signals = [];
let riskScore = 0;
// Cloudflare bot skoru (düşük = daha bot)
if (cf.botManagement) {
const botScore = cf.botManagement.score;
if (botScore < 30) {
signals.push("low_bot_score");
riskScore += 40;
} else if (botScore < 60) {
riskScore += 15;
}
}
// Threat score kontrolü
if (cf.threatScore && cf.threatScore > 10) {
signals.push("high_threat_score");
riskScore += cf.threatScore;
}
// ASN kontrolü
if (cf.asn && SUSPICIOUS_ASNS.includes(`AS${cf.asn}`)) {
signals.push("suspicious_asn");
riskScore += 30;
}
// User-Agent analizi
const ua = request.headers.get("user-agent") || "";
if (!ua || ua.length < 10) {
signals.push("missing_or_short_ua");
riskScore += 25;
}
// Tor exit node kontrolü
if (cf.isEUCountry === false && cf.country === "T1") {
signals.push("tor_exit_node");
riskScore += 50;
}
// Headers tutarlılığı
const acceptHeader = request.headers.get("accept");
const acceptLang = request.headers.get("accept-language");
if (!acceptHeader || !acceptLang) {
signals.push("missing_browser_headers");
riskScore += 20;
}
return {
riskScore,
signals,
blocked: riskScore >= 70,
challenged: riskScore >= 40 && riskScore < 70
};
}
Ana Worker Mantığı
Şimdi her şeyi bir araya getirelim. Login endpoint’ine gelen POST isteklerini edge’de yakalıyoruz; diğer her şeyi origin’e geçiriyoruz:
// src/index.js
import { checkRateLimit, recordSuccessfulLogin } from "./rateLimit.js";
import { createJWT, verifyJWT } from "./jwt.js";
import { analyzeBotSignals } from "./botDetection.js";
export default {
async fetch(request, env, ctx) {
const url = new URL(request.url);
const ip = request.headers.get("CF-Connecting-IP") || "unknown";
// Sadece login endpoint'ini handle et
if (url.pathname === "/api/auth/login" && request.method === "POST") {
return handleLogin(request, env, ip);
}
// Korumalı route'ları kontrol et
if (url.pathname.startsWith("/api/") && url.pathname !== "/api/auth/login") {
const authResult = await handleAuthCheck(request, env);
if (!authResult.authenticated) {
return new Response(JSON.stringify({
error: "Yetkisiz erişim",
code: "UNAUTHORIZED"
}), {
status: 401,
headers: { "Content-Type": "application/json" }
});
}
}
// Origin'e geçir
return fetch(request);
}
};
async function handleLogin(request, env, ip) {
// Bot analizi
const botAnalysis = analyzeBotSignals(request);
if (botAnalysis.blocked) {
return new Response(JSON.stringify({
error: "Erişim reddedildi",
code: "BOT_DETECTED"
}), {
status: 403,
headers: { "Content-Type": "application/json" }
});
}
// Rate limit kontrolü
const maxAttempts = parseInt(env.MAX_ATTEMPTS || "5");
const lockoutDuration = parseInt(env.LOCKOUT_DURATION || "900");
const rateLimitResult = await checkRateLimit(
ip,
env.RATE_LIMIT_KV,
maxAttempts,
lockoutDuration
);
if (!rateLimitResult.allowed) {
return new Response(JSON.stringify({
error: "Çok fazla deneme yapıldı. Lütfen bekleyin.",
retryAfter: rateLimitResult.retryAfter,
code: rateLimitResult.reason
}), {
status: 429,
headers: {
"Content-Type": "application/json",
"Retry-After": String(rateLimitResult.retryAfter || 900),
"X-RateLimit-Remaining": "0"
}
});
}
// İsteği origin'e ilet, credential doğrulaması orada
let body;
try {
body = await request.json();
} catch {
return new Response(JSON.stringify({ error: "Geçersiz istek gövdesi" }), {
status: 400,
headers: { "Content-Type": "application/json" }
});
}
// Origin'den auth sonucunu al
const originResponse = await fetch(request.url.replace("/api/auth/login", "/internal/auth/verify"), {
method: "POST",
headers: {
"Content-Type": "application/json",
"X-Internal-Secret": env.INTERNAL_SECRET,
"X-Original-IP": ip
},
body: JSON.stringify(body)
});
const originData = await originResponse.json();
if (!originResponse.ok || !originData.success) {
// Başarısız deneme kaydedildi (KV'de otomatik sayıldı)
return new Response(JSON.stringify({
error: "Kullanıcı adı veya şifre hatalı",
remaining: rateLimitResult.remaining - 1
}), {
status: 401,
headers: { "Content-Type": "application/json" }
});
}
// Başarılı giriş - rate limit sıfırla, JWT üret
await recordSuccessfulLogin(ip, env.RATE_LIMIT_KV);
const token = await createJWT({
sub: originData.userId,
email: originData.email,
role: originData.role,
ip: ip
}, env.JWT_SECRET, 3600);
return new Response(JSON.stringify({
success: true,
token,
expiresIn: 3600
}), {
status: 200,
headers: {
"Content-Type": "application/json",
"Set-Cookie": `auth_token=${token}; HttpOnly; Secure; SameSite=Strict; Max-Age=3600; Path=/`
}
});
}
async function handleAuthCheck(request, env) {
const authHeader = request.headers.get("Authorization");
const cookieHeader = request.headers.get("Cookie");
let token = null;
if (authHeader && authHeader.startsWith("Bearer ")) {
token = authHeader.substring(7);
} else if (cookieHeader) {
const match = cookieHeader.match(/auth_token=([^;]+)/);
if (match) token = match[1];
}
if (!token) {
return { authenticated: false, reason: "no_token" };
}
const result = await verifyJWT(token, env.JWT_SECRET);
return {
authenticated: result.valid,
payload: result.payload,
reason: result.error
};
}
Deploy ve Test
# Geliştirme ortamında test
wrangler dev --local
# KV'yi test verisiyle doldur
wrangler kv:key put --binding=RATE_LIMIT_KV "test_key" "test_value"
# Production'a deploy
wrangler deploy
# Başarılı deploy sonrası log takibi
wrangler tail --format=pretty
Lokal test için birkaç curl komutu:
# Normal login denemesi
curl -X POST http://localhost:8787/api/auth/login
-H "Content-Type: application/json"
-H "CF-Connecting-IP: 1.2.3.4"
-d '{"username":"[email protected]","password":"test123"}'
# Rate limit testini simüle et (aynı IP'den 6 istek)
for i in {1..6}; do
echo "Deneme $i:"
curl -s -X POST http://localhost:8787/api/auth/login
-H "Content-Type: application/json"
-H "CF-Connecting-IP: 10.0.0.1"
-d '{"username":"[email protected]","password":"yanlis"}'
| python3 -m json.tool
sleep 0.5
done
# JWT ile korumalı endpoint erişimi
TOKEN="eyJ..." # login'den alınan token
curl http://localhost:8787/api/user/profile
-H "Authorization: Bearer $TOKEN"
Coğrafi Kısıtlama (Opsiyonel)
Eğer sisteminiz sadece Türkiye’ye hizmet veriyorsa ve uluslararası erişim istemiyorsanız, cf.country kullanarak bu kontrolü kolayca ekleyebilirsiniz. Dikkat: bu kısıtlamayı sadece gerçekten ihtiyaç duyduğunuzda kullanın, VPN kullanan meşru kullanıcıları engelleyebilirsiniz:
// src/geoRestriction.js
const ALLOWED_COUNTRIES = ["TR", "CY", "AZ", "DE"]; // Hedef pazarınıza göre
export function checkGeoRestriction(request, enabled) {
if (!enabled) return { allowed: true };
const country = request.cf?.country || "XX";
if (!ALLOWED_COUNTRIES.includes(country)) {
return {
allowed: false,
country,
reason: "geo_restricted"
};
}
return { allowed: true, country };
}
Bu fonksiyonu handleLogin içinde şu şekilde çağırırsınız:
const geoRestrict = env.GEO_RESTRICT === "true";
const geoResult = checkGeoRestriction(request, geoRestrict);
if (!geoResult.allowed) {
return new Response(JSON.stringify({
error: "Bu hizmet bulunduğunuz bölgede kullanılamamaktadır.",
code: "GEO_RESTRICTED"
}), { status: 403, headers: { "Content-Type": "application/json" } });
}
Monitoring ve Alerting
Production’da şüpheli aktiviteyi izlemek için Workers Analytics Engine kullanabilirsiniz ya da basitçe console.log ile Wrangler tail üzerinden takip edebilirsiniz. Daha ciddi bir monitoring için:
// src/analytics.js
export async function logSecurityEvent(env, eventType, data) {
const event = {
timestamp: new Date().toISOString(),
type: eventType,
...data
};
// Opsiyonel: harici log servise gönder (Datadog, Grafana Loki vb.)
if (env.LOG_ENDPOINT) {
try {
await fetch(env.LOG_ENDPOINT, {
method: "POST",
headers: {
"Content-Type": "application/json",
"Authorization": `Bearer ${env.LOG_API_KEY}`
},
body: JSON.stringify(event)
});
} catch {
// Log hataları asla ana akışı engellememelidir
console.error("Log gönderilemedi:", eventType);
}
}
console.log(JSON.stringify(event));
}
Security event’leri şöyle kategorize edebilirsiniz:
- login_blocked_ratelimit: IP kilitlendiğinde
- login_blocked_bot: Bot skoru yüksek olduğunda
- login_failed: Hatalı credential
- login_success: Başarılı giriş
- auth_token_expired: Süresi dolmuş token erişimi
- auth_token_invalid: Bozuk/manipüle edilmiş token
Bilinen Sınırlılıklar ve Dikkat Edilmesi Gerekenler
Workers’ın bazı kısıtlamaları var ve bunları baştan bilmek, sonradan sürprizle karşılaşmaktan kurtarıyor:
- CPU süresi: Tek bir Worker isteği maksimum 50ms CPU süresi kullanabilir (paid plan’da 30 saniye). Karmaşık şifreleme işlemleri dikkat gerektirir.
- KV gecikme: KV strongly consistent değil, eventually consistent. Aynı veri merkezinden okuma genelde güncel, farklı bölgeler arasında kısa bir lag olabilir. Rate limiting için bu kabul edilebilir.
- Gizli değişkenler:
env.JWT_SECRETgibi değerleri kesinliklewrangler.toml‘a yazmayın, her zamanwrangler secret putkullanın. - IP manipülasyonu:
CF-Connecting-IPheaderı Cloudflare tarafından ekleniyor, güvenilir. Ama origin sunucunuz direkt erişime açıksa bu header manipüle edilebilir. Origin’i sadece Cloudflare IP’lerine açın. - Token blacklisting: Bu implementasyonda logout sonrası token invalidation yok. Bunu yapmak için KV’de bir revocation list tutmanız gerekiyor; trade-off olarak her auth check’te KV’ye ek bir lookup yapılır.
Sonuç
Bu yapıyla elde ettiğimiz şey şu: kötü niyetli trafik Türkiye’deki bir Cloudflare edge node’unda ölüyor, origin sunucunuz sadece meşru trafikle uğraşıyor. Müşterimizin sunucu yükü brute force saldırısı sırasında bu yapıya geçtikten sonra yüzde seksen düştü. Load balancer loglarında “syn flood” satırları kayboldu.
Tabii bu tek başına yeterli değil. Origin’de de kendi auth validasyonunuzu tutun, X-Internal-Secret ile Worker’dan gelen istekleri verify edin, HTTPS her yerde zorunlu olsun. Güvenlik katmanlı bir yaklaşım gerektiriyor; Workers burada giriş kapısındaki akıllı muhafız rolünü üstleniyor, asıl sorumluluk hala sizde.
Wrangler CLI ve Workers ekosistemi hızla olgunlaşıyor. Durable Objects ile daha güçlü state yönetimi, D1 ile SQLite tabanlı session storage gibi seçenekler artık production’a hazır. Bu temel yapının üstüne bunları da inşa edebilirsiniz.
